Flutter Firebase Integration: Auth, Firestore, and Push Notifications

14 min readFebruary 9, 2026Updated: Mar 9, 2026
Flutter FirebaseFlutter FirestoreFlutter Firebase AuthFlutter push notificationFlutter FCMFlutter CrashlyticsFirebase security rulesFlutter backend

# Flutter Firebase Integration: Auth, Firestore, and Push Notifications

Firebase can dramatically speed up product delivery when configured correctly. Over the years, I have shipped multiple Flutter apps backed by Firebase, and each project taught me something new about what works at scale and what falls apart under pressure. This article covers the practical side of integrating Firebase into a Flutter project, from authentication flows to Firestore data modeling, push notifications, and crash reporting, along with the hard-won lessons that only show up in production.

Project Setup with FlutterFire CLI

Before writing any Firebase code, you need a clean project setup. The FlutterFire CLI eliminates the manual google-services.json dance entirely:

dart
class=class="code-string">"code-comment">// Install FlutterFire CLI globally
class=class="code-string">"code-comment">// dart pub global activate flutterfire_cli

class=class="code-string">"code-comment">// Then in your project root:
class=class="code-string">"code-comment">// flutterfire configure --project=your-firebase-project-id

class=class="code-string">"code-comment">// In main.dart
import class="code-string">'package:firebase_core/firebase_core.dart';
import class="code-string">'firebase_options.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const MyApp());
}

In my Firebase-powered apps, I always keep separate Firebase projects for development and production. Mixing environments leads to corrupted analytics, test push notifications reaching real users, and security rules that are too permissive because "we needed it for testing."

Firebase Authentication

Email and Password Auth

The most common starting point. Here is a full registration and login flow with proper error handling:

dart
class AuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;

  class=class="code-string">"code-comment">// Register
  Future<UserCredential?> register(String email, String password) async {
    try {
      final credential = await _auth.createUserWithEmailAndPassword(
        email: email,
        password: password,
      );
      class=class="code-string">"code-comment">// Send email verification
      await credential.user?.sendEmailVerification();
      return credential;
    } on FirebaseAuthException catch (e) {
      switch (e.code) {
        case class="code-string">'email-already-in-use':
          throw AuthError(class="code-string">'This email is already registered.');
        case class="code-string">'weak-password':
          throw AuthError(class="code-string">'Password must be at least class="code-number">6 characters.');
        case class="code-string">'invalid-email':
          throw AuthError(class="code-string">'Please enter a valid email address.');
        default:
          throw AuthError(class="code-string">'Registration failed. Please try again.');
      }
    }
  }

  class=class="code-string">"code-comment">// Login
  Future<UserCredential?> login(String email, String password) async {
    try {
      return await _auth.signInWithEmailAndPassword(
        email: email,
        password: password,
      );
    } on FirebaseAuthException catch (e) {
      switch (e.code) {
        case class="code-string">'user-not-found':
        case class="code-string">'wrong-password':
          throw AuthError(class="code-string">'Invalid email or password.');
        case class="code-string">'user-disabled':
          throw AuthError(class="code-string">'This account has been disabled.');
        default:
          throw AuthError(class="code-string">'Login failed. Please try again.');
      }
    }
  }

  class=class="code-string">"code-comment">// Auth state stream for reactive UI
  Stream<User?> get authStateChanges => _auth.authStateChanges();

  class=class="code-string">"code-comment">// Sign out
  Future<void> signOut() => _auth.signOut();
}

Google Sign-In

dart
Future<UserCredential?> signInWithGoogle() async {
  final GoogleSignInAccount? googleUser = await GoogleSignIn().signIn();
  if (googleUser == null) return null; class=class="code-string">"code-comment">// User cancelled

  final GoogleSignInAuthentication googleAuth =
      await googleUser.authentication;

  final credential = GoogleAuthProvider.credential(
    accessToken: googleAuth.accessToken,
    idToken: googleAuth.idToken,
  );

  return await FirebaseAuth.instance.signInWithCredential(credential);
}

Creating a User Profile After Registration

In my Firebase-powered apps, I always create a Firestore user document immediately after registration. Relying solely on FirebaseAuth user records is limiting; you will inevitably need custom fields like roles, preferences, or subscription status:

dart
Future<void> createUserProfile(User user) async {
  final docRef = FirebaseFirestore.instance.collection(class="code-string">'users').doc(user.uid);
  final doc = await docRef.get();

  if (!doc.exists) {
    await docRef.set({
      class="code-string">'email': user.email,
      class="code-string">'displayName': user.displayName ?? class="code-string">'',
      class="code-string">'photoUrl': user.photoURL ?? class="code-string">'',
      class="code-string">'role': class="code-string">'user',
      class="code-string">'createdAt': FieldValue.serverTimestamp(),
      class="code-string">'lastLoginAt': FieldValue.serverTimestamp(),
    });
  } else {
    await docRef.update({
      class="code-string">'lastLoginAt': FieldValue.serverTimestamp(),
    });
  }
}

Cloud Firestore

Data Modeling and CRUD Operations

Firestore is a document database, so you need to think in terms of collections and documents rather than tables and rows. Here is a type-safe approach using model classes:

dart
class Task {
  final String id;
  final String title;
  final String description;
  final bool isCompleted;
  final DateTime createdAt;
  final String userId;

  Task({
    required this.id,
    required this.title,
    required this.description,
    required this.isCompleted,
    required this.createdAt,
    required this.userId,
  });

  factory Task.fromFirestore(DocumentSnapshot doc) {
    final data = doc.data() as Map<String, dynamic>;
    return Task(
      id: doc.id,
      title: data[class="code-string">'title'] ?? class="code-string">'',
      description: data[class="code-string">'description'] ?? class="code-string">'',
      isCompleted: data[class="code-string">'isCompleted'] ?? false,
      createdAt: (data[class="code-string">'createdAt'] as Timestamp).toDate(),
      userId: data[class="code-string">'userId'] ?? class="code-string">'',
    );
  }

  Map<String, dynamic> toFirestore() => {
    class="code-string">'title': title,
    class="code-string">'description': description,
    class="code-string">'isCompleted': isCompleted,
    class="code-string">'createdAt': Timestamp.fromDate(createdAt),
    class="code-string">'userId': userId,
  };
}

Repository Pattern for Firestore

dart
class TaskRepository {
  final _collection = FirebaseFirestore.instance.collection(class="code-string">'tasks');

  class=class="code-string">"code-comment">// Create
  Future<String> createTask(Task task) async {
    final docRef = await _collection.add(task.toFirestore());
    return docRef.id;
  }

  class=class="code-string">"code-comment">// Read (real-time stream)
  Stream<List<Task>> watchUserTasks(String userId) {
    return _collection
        .where(class="code-string">'userId', isEqualTo: userId)
        .orderBy(class="code-string">'createdAt', descending: true)
        .snapshots()
        .map((snapshot) =>
            snapshot.docs.map((doc) => Task.fromFirestore(doc)).toList());
  }

  class=class="code-string">"code-comment">// Update
  Future<void> updateTask(String taskId, Map<String, dynamic> updates) {
    return _collection.doc(taskId).update(updates);
  }

  class=class="code-string">"code-comment">// Delete
  Future<void> deleteTask(String taskId) {
    return _collection.doc(taskId).delete();
  }

  class=class="code-string">"code-comment">// Batch operations for bulk updates
  Future<void> completeAllTasks(String userId) async {
    final batch = FirebaseFirestore.instance.batch();
    final tasks = await _collection
        .where(class="code-string">'userId', isEqualTo: userId)
        .where(class="code-string">'isCompleted', isEqualTo: false)
        .get();

    for (final doc in tasks.docs) {
      batch.update(doc.reference, {class="code-string">'isCompleted': true});
    }
    await batch.commit();
  }
}

Offline Support

Firestore has built-in offline persistence on mobile, but you need to handle it consciously:

dart
class=class="code-string">"code-comment">// Enable offline persistence with cache size limit
FirebaseFirestore.instance.settings = const Settings(
  persistenceEnabled: true,
  cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
);

class=class="code-string">"code-comment">// Check if data comes from cache or server
stream.listen((snapshot) {
  for (final change in snapshot.docChanges) {
    if (snapshot.metadata.isFromCache) {
      class=class="code-string">"code-comment">// Data is from local cache, show indicator to user
    }
  }
});

Firestore Security Rules

This is where most Firebase projects fail. Open security rules are the number one Firebase security mistake, and I have seen production apps with `allow read, write: if true;` rules that expose every user's data to anyone.

javascript
rules_version = class="code-string">'class="code-number">2';
service cloud.firestore {
  match /databases/{database}/documents {

    class=class="code-string">"code-comment">// User profiles: users can only read/write their own
    match /users/{userId} {
      allow read: if request.auth != null && request.auth.uid == userId;
      allow create: if request.auth != null && request.auth.uid == userId;
      allow update: if request.auth != null && request.auth.uid == userId
                    && !request.resource.data.diff(resource.data).affectedKeys()
                        .hasAny([class="code-string">'role', class="code-string">'createdAt']);
      class=class="code-string">"code-comment">// Users cannot change their own role or creation date
    }

    class=class="code-string">"code-comment">// Tasks: users can only access their own tasks
    match /tasks/{taskId} {
      allow read: if request.auth != null
                  && resource.data.userId == request.auth.uid;
      allow create: if request.auth != null
                    && request.resource.data.userId == request.auth.uid
                    && request.resource.data.keys().hasAll([class="code-string">'title', class="code-string">'userId', class="code-string">'createdAt']);
      allow update: if request.auth != null
                    && resource.data.userId == request.auth.uid;
      allow delete: if request.auth != null
                    && resource.data.userId == request.auth.uid;
    }

    class=class="code-string">"code-comment">// Admin-only collection
    match /admin/{document=**} {
      allow read, write: if request.auth != null
                         && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == class="code-string">'admin';
    }
  }
}

In my Firebase-powered apps, I always write security rules tests using the Firebase Emulator Suite before deploying. A broken rule can silently lock users out or silently expose data.

Firebase Cloud Messaging (FCM)

Setup and Token Management

dart
class PushNotificationService {
  final FirebaseMessaging _messaging = FirebaseMessaging.instance;

  Future<void> initialize() async {
    class=class="code-string">"code-comment">// Request permission (required on iOS, good practice on Android class="code-number">13+)
    final settings = await _messaging.requestPermission(
      alert: true,
      badge: true,
      sound: true,
      provisional: false,
    );

    if (settings.authorizationStatus == AuthorizationStatus.authorized) {
      await _setupToken();
      _configureForegroundHandler();
      _configureBackgroundHandler();
    }
  }

  Future<void> _setupToken() async {
    class=class="code-string">"code-comment">// Get the token
    final token = await _messaging.getToken();
    if (token != null) {
      await _saveTokenToFirestore(token);
    }

    class=class="code-string">"code-comment">// Listen for token refresh
    _messaging.onTokenRefresh.listen(_saveTokenToFirestore);
  }

  Future<void> _saveTokenToFirestore(String token) async {
    final user = FirebaseAuth.instance.currentUser;
    if (user != null) {
      await FirebaseFirestore.instance.collection(class="code-string">'users').doc(user.uid).update({
        class="code-string">'fcmTokens': FieldValue.arrayUnion([token]),
        class="code-string">'lastTokenUpdate': FieldValue.serverTimestamp(),
      });
    }
  }

  void _configureForegroundHandler() {
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      class=class="code-string">"code-comment">// Show local notification or in-app banner
      if (message.notification != null) {
        _showLocalNotification(message);
      }
    });
  }

  static Future<void> _configureBackgroundHandler() async {
    FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);
  }
}

class=class="code-string">"code-comment">// Must be a top-level function
@pragma(class="code-string">'vm:entry-point')
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
  await Firebase.initializeApp();
  class=class="code-string">"code-comment">// Handle background message (keep it lightweight)
}

Topic-Based Notifications

dart
class=class="code-string">"code-comment">// Subscribe users to topics for targeted messaging
await FirebaseMessaging.instance.subscribeToTopic(class="code-string">'news');
await FirebaseMessaging.instance.subscribeToTopic(class="code-string">'promo_en');

class=class="code-string">"code-comment">// Unsubscribe
await FirebaseMessaging.instance.unsubscribeFromTopic(class="code-string">'promo_en');

Crashlytics Integration

Crashlytics is non-negotiable for production apps. Here is the setup I use in every project:

dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);

  class=class="code-string">"code-comment">// Pass all uncaught Flutter errors to Crashlytics
  FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;

  class=class="code-string">"code-comment">// Pass all uncaught async errors to Crashlytics
  PlatformDispatcher.instance.onError = (error, stack) {
    FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
    return true;
  };

  runApp(const MyApp());
}

class=class="code-string">"code-comment">// Custom logging for non-fatal errors
void logError(dynamic error, StackTrace? stack, {String? reason}) {
  FirebaseCrashlytics.instance.recordError(
    error,
    stack,
    reason: reason ?? class="code-string">'Non-fatal error',
  );
}

class=class="code-string">"code-comment">// Set user identifier for crash reports
void setUserForCrashReporting(String userId) {
  FirebaseCrashlytics.instance.setUserIdentifier(userId);
  FirebaseCrashlytics.instance.setCustomKey(class="code-string">'user_role', class="code-string">'premium');
}

Common Firebase Mistakes

These are the mistakes I see most often in Firebase projects, including some I have made myself:

1. Open Security Rules

The default test-mode rules expire after 30 days, but many teams forget to replace them. Your entire database is publicly readable and writable. Always write proper rules before your first deploy.

2. No Offline Handling

Firestore operations queue silently when offline. If your app does not communicate this to the user, they may think their data was saved when it is still pending. Always check `snapshot.metadata.hasPendingWrites` and show appropriate UI.

3. Unbounded Queries

Fetching an entire collection without `.limit()` is a ticking time bomb. It works fine with 50 documents and crashes with 50,000. Always paginate:

dart
class=class="code-string">"code-comment">// Paginated query
Query<Map<String, dynamic>> paginatedQuery(DocumentSnapshot? lastDoc) {
  var query = _collection.orderBy(class="code-string">'createdAt').limit(class="code-number">20);
  if (lastDoc != null) {
    query = query.startAfterDocument(lastDoc);
  }
  return query;
}

4. Storing Derived Data Client-Side Only

If you need a "task count" or "unread message count," do not compute it on every read. Use Cloud Functions to maintain counters as documents change. Client-side aggregation does not scale.

5. Ignoring Token Refresh for FCM

FCM tokens rotate without warning. If you save the token once at registration and never update it, push notifications will silently stop working for those users after weeks or months.

6. Not Using Transactions for Concurrent Writes

When multiple users or devices can modify the same document, you need transactions:

dart
Future<void> incrementLikeCount(String postId) {
  return FirebaseFirestore.instance.runTransaction((transaction) async {
    final postRef = FirebaseFirestore.instance.collection(class="code-string">'posts').doc(postId);
    final snapshot = await transaction.get(postRef);
    final currentLikes = snapshot.data()?[class="code-string">'likes'] ?? class="code-number">0;
    transaction.update(postRef, {class="code-string">'likes': currentLikes + class="code-number">1});
  });
}

Cost Optimization

Firebase pricing can surprise you if you are not paying attention. Here are the strategies I follow:

Read/Write Optimization

  • **Use `.limit()` on every query.** Firestore charges per document read, so an unbound query on a 10K-document collection costs 10K reads every time.
  • **Cache aggressively.** Firestore's offline cache means repeat visits cost zero reads. Use `GetOptions(source: Source.cache)` for data that does not change often.
  • **Use `select()` to fetch only the fields you need** when working with large documents (Firestore still charges one read, but it reduces bandwidth).
  • Storage and Bandwidth

  • Compress images before uploading to Firebase Storage. A 5MB photo uploaded by 1,000 users costs real money in storage and bandwidth.
  • Set lifecycle rules to delete temporary files automatically.
  • Cloud Functions

  • Avoid chained function invocations where one function triggers another. Each invocation is billed separately.
  • Use batched writes instead of writing documents one at a time in a loop within a Cloud Function.
  • Monitoring

  • Set up **budget alerts** in the Firebase console. I have seen hobby projects rack up unexpected bills because a development loop triggered millions of reads.
  • Use the Firebase Usage tab to identify which collections are generating the most reads.
  • In my Firebase-powered apps, I always set up billing alerts at 50%, 80%, and 100% of my expected monthly budget. The $0.06 per 100K reads sounds cheap until your app goes viral.

    Production Checklist

    Before launching a Firebase-backed Flutter app, run through this list:

  • [ ] Security rules are locked down and tested with the Firebase Emulator
  • [ ] Separate Firebase projects for development and production
  • [ ] Environment-specific configuration (API keys, project IDs) managed via flavor or env files
  • [ ] Email verification flow is implemented and enforced
  • [ ] FCM token refresh is handled and stale tokens are cleaned up
  • [ ] Crashlytics is configured with user identifiers and custom keys
  • [ ] All Firestore queries use `.limit()` or pagination
  • [ ] Offline state is communicated to the user with appropriate UI
  • [ ] Firestore indexes are created for compound queries (check the console for missing index errors)
  • [ ] Cloud Functions have error handling and retry logic
  • [ ] Budget alerts are configured in the Google Cloud Console
  • [ ] App Check is enabled to prevent unauthorized API access
  • [ ] Data export and backup strategy is documented
  • [ ] Privacy policy covers Firebase Analytics and Crashlytics data collection
  • Conclusion

    Firebase is a powerful accelerator for Flutter apps, but it demands architectural discipline from the start. Authentication, Firestore data modeling, push notifications, and crash reporting are the core pillars, and each one has subtle pitfalls that only appear at scale. The difference between a Firebase prototype and a Firebase production app is security rules, offline handling, cost awareness, and proper monitoring.

    Contact me for a production-readiness Firebase review.

    Related Articles

    Have a Flutter Project?

    I build high-performance Flutter applications for iOS, Android, and web.

    Get in Touch