Firebase Essentials: Auth, FCM, Crashlytics, Firestore
Production Firebase Integration Every Flutter Dev Must Know
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Firebase provides a suite of production services for Flutter apps: Firebase Auth for identity, FCM for push notifications, Crashlytics for crash reporting, Firestore for real-time cloud database, and Remote Config for feature flags. Understanding the correct integration patterns and security model is essential for production apps.
Real-world relevance
In a school management platform: Firebase Auth handles teacher/admin login with Google Sign-In and custom backend JWT via ID tokens. Crashlytics captures production crashes with userId and screenName breadcrumbs. FCM sends instant notifications when a student submits an assignment. Firestore powers the real-time attendance board with live document listeners. Remote Config enables/disables the beta AI feedback feature per school without an app store update.
Key points
- Firebase Auth — Multiple Flows — Firebase Auth handles email/password, Google Sign-In, Apple Sign-In, phone OTP, and anonymous auth. Key concepts: User object persists across app restarts via FirebaseAuth.instance.currentUser. authStateChanges() stream rebuilds UI on sign-in/sign-out. idToken() provides a JWT for authenticating backend API calls. Refresh tokens automatically — the SDK handles expiry.
- Auth State as the Router Guard — Wrap your app router with an authStateChanges() StreamBuilder. If user is null, show onboarding/login. If user is non-null, show the authenticated app. With GoRouter: use redirect and refreshListenable (a ChangeNotifier wrapping the auth stream) to automatically redirect on auth state changes. This is the production pattern for auth-gated routing.
- ID Token for Backend Authentication — When your app uses a custom backend not just Firestore, pass Firebase's ID token to your API: final token = await user.getIdToken(). The backend verifies this token using the Firebase Admin SDK — no custom JWT infrastructure needed. The token auto-refreshes every hour — getIdToken() returns a cached token and refreshes it transparently.
- FCM Push Notifications — Firebase Cloud Messaging delivers push notifications in foreground, background, and terminated state. Three notification types: display notification (system handles UI), data message (app handles in background handler), notification plus data (both). Handle all three states: FirebaseMessaging.onMessage for foreground, FirebaseMessaging.onMessageOpenedApp for background tap, FirebaseMessaging.instance.getInitialMessage for terminated tap.
- FCM Token Management — Each device has an FCM token — a unique address for push delivery. Register the token to your backend on login. Refresh the token when FirebaseMessaging.instance.onTokenRefresh fires (tokens change when app is reinstalled or user clears data). Delete the token on logout. Without token refresh handling, users stop receiving notifications after reinstalling.
- Crashlytics Integration — Firebase Crashlytics captures fatal crashes automatically. For Flutter errors: FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError. For Dart async errors: PlatformDispatcher.instance.onError. Set user identifiers: crashlytics.setUserIdentifier(userId). Add custom keys: crashlytics.setCustomKey('workspace_id', workspaceId). Log breadcrumbs: crashlytics.log('User opened payment screen').
- Firestore Queries and Indexes — Firestore is a NoSQL document database with real-time listeners. Key query patterns: collection().where().orderBy().limit() requires a composite index for where plus orderBy. Subcollection pattern for scalable data modeling. snapshots() returns a Stream — use StreamBuilder for real-time UI. Firestore charges per read — design your data model to minimize reads per screen load.
- Firestore Security Rules — Security rules run server-side and are the only real authorization layer for Firestore. Never trust client-side auth alone. Rules: allow read if request.auth.uid == resource.data.userId. For complex apps, Firestore rules become complex — test them with the Firebase emulator and the Rules Playground. Missing rules are the number one security vulnerability in Firebase-backed apps.
- Remote Config for Feature Flags — Firebase Remote Config delivers key-value parameters to the app without a store update. Use for: feature flags like enable_new_checkout, experiment parameters, emergency kill switches like maintenance_mode. Fetch with setConfigSettings(minimumFetchInterval) — in production, use a 1-hour interval. Apply with activate(). Check with getBool('enable_new_checkout').
- Firestore Offline Persistence — Firestore has built-in offline persistence enabled by default on mobile. Reads return cached data when offline. Writes are queued and synced when online automatically. This gives basic offline support for free, but it is not the full offline-first architecture from Lesson 26 — there is no sync queue, no conflict resolution, and no control over sync behavior.
- Firebase Emulator for Local Dev — The Firebase Local Emulator Suite runs Auth, Firestore, FCM, and more locally. No billing, no production data risk. In Flutter: use dart-define to switch Firebase config between emulator and production. CI pipelines should run against the emulator. This is the production development workflow — never test FCM or Firestore against production during development.
Code example
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_remote_config/firebase_remote_config.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Crashlytics — catch all Flutter and Dart errors
FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError;
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
// FCM background handler must be top-level
FirebaseMessaging.onBackgroundMessage(_fcmBackgroundHandler);
runApp(const App());
}
@pragma('vm:entry-point')
Future<void> _fcmBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
await _processNotificationData(message.data);
}
// Auth with GoRouter
class AuthNotifier extends ChangeNotifier {
final FirebaseAuth _auth;
User? _currentUser;
StreamSubscription<User?>? _sub;
AuthNotifier(this._auth) {
_sub = _auth.authStateChanges().listen((user) {
_currentUser = user;
notifyListeners();
});
}
bool get isAuthenticated => _currentUser != null;
Future<String?> getIdToken() => _currentUser?.getIdToken();
Future<void> signOut() => _auth.signOut();
@override
void dispose() { _sub?.cancel(); super.dispose(); }
}
// FCM Token Management
class NotificationService {
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final UserApi _api;
NotificationService(this._api);
Future<void> initialize() async {
final settings = await _messaging.requestPermission(alert: true, badge: true, sound: true);
if (settings.authorizationStatus != AuthorizationStatus.authorized) return;
final token = await _messaging.getToken();
if (token != null) await _api.registerFcmToken(token);
_messaging.onTokenRefresh.listen((newToken) => _api.registerFcmToken(newToken));
FirebaseMessaging.onMessage.listen(_handleForeground);
FirebaseMessaging.onMessageOpenedApp.listen(_handleTap);
final initial = await _messaging.getInitialMessage();
if (initial != null) _handleTap(initial);
}
void _handleForeground(RemoteMessage message) =>
debugPrint('Foreground: ${message.notification?.title}');
void _handleTap(RemoteMessage message) {
final screen = message.data['screen'] as String?;
if (screen != null) router.push(screen);
}
}
// Firestore Real-Time Query
class AttendanceRepository {
final FirebaseFirestore _db = FirebaseFirestore.instance;
Stream<List<AttendanceRecord>> watchAttendance({
required String classId, required DateTime date,
}) {
return _db.collection('classes').doc(classId)
.collection('attendance')
.where('date', isEqualTo: Timestamp.fromDate(date))
.orderBy('studentName')
.snapshots()
.map((snap) => snap.docs.map((d) => AttendanceRecord.fromDoc(d)).toList());
}
Future<void> markAttendance({
required String classId, required String studentId, required bool present,
}) async {
await _db.collection('classes').doc(classId)
.collection('attendance').doc(studentId)
.set({
'studentId': studentId, 'present': present,
'markedAt': FieldValue.serverTimestamp(),
'markedBy': FirebaseAuth.instance.currentUser?.uid,
}, SetOptions(merge: true));
}
}
// Remote Config Feature Flags
class FeatureFlagService {
final FirebaseRemoteConfig _rc = FirebaseRemoteConfig.instance;
Future<void> initialize() async {
await _rc.setConfigSettings(RemoteConfigSettings(
fetchTimeout: const Duration(seconds: 10),
minimumFetchInterval: const Duration(hours: 1),
));
await _rc.setDefaults({'enable_ai_feedback': false, 'max_class_size': 40});
await _rc.fetchAndActivate();
}
bool get isAiFeedbackEnabled => _rc.getBool('enable_ai_feedback');
}Line-by-line walkthrough
- 1. Firebase.initializeApp must complete before any Firebase service is used
- 2. FlutterError.onError catches Flutter widget tree errors and framework exceptions
- 3. PlatformDispatcher.instance.onError catches errors in async code outside the Flutter framework
- 4. FCM background handler must be top-level — it runs in a separate isolate with no access to class state
- 5. AuthNotifier wraps authStateChanges() in a ChangeNotifier — GoRouter uses this for reactive routing
- 6. getIdToken() returns a cached JWT, refreshing automatically when expired
- 7. requestPermission on iOS shows the system dialog — check the result before registering the token
- 8. onTokenRefresh fires when FCM assigns a new token — always update your backend
- 9. where plus orderBy requires a composite Firestore index — create it in the Firebase Console
- 10. FieldValue.serverTimestamp() uses the Firestore server time — avoids clock skew issues
- 11. minimumFetchInterval of 1 hour prevents excessive Remote Config fetches
Spot the bug
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
runApp(const App());
}
class NotificationSetup extends StatefulWidget { }
class _State extends State<NotificationSetup> {
@override
void initState() {
super.initState();
FirebaseMessaging.onBackgroundMessage((message) async {
print('Background: ${message.notification?.title}');
});
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- FlutterFire Documentation (FlutterFire)
- Firebase Crashlytics Flutter (FlutterFire)
- FCM Flutter Setup (FlutterFire)
- Firestore Security Rules (Firebase Official)
- Firebase Remote Config Flutter (FlutterFire)