How to Build a Flutter App with Firebase Auth and Firestore

June 2026 · Published by Amar Kumar

Most mobile apps need the same foundation: sign in with Google or Apple, persist user data in the cloud, and react to auth state across screens. Flutter plus Firebase covers all three without maintaining separate native auth SDKs for iOS and Android.

This is a deep-dive setup guide — from flutter create through Google Sign-In, Sign in with Apple, Cloud Firestore, Riverpod state, and GoRouter auth redirects. Just the engineering path to a working mobile app skeleton you can extend.

Who is this for? Developers who know basic Dart and want a production-shaped mobile stack — auth, cloud database, routing, and state — without boilerplate guesswork.

Why Flutter + Firebase

LayerFlutterFirebase
UISingle Dart codebase for iOS, Android, web
Authfirebase_auth + platform sign-in packagesGoogle, Apple, email providers
Databasecloud_firestore streams into widgetsReal-time NoSQL, offline cache
Filesfirebase_storageObject storage for avatars, uploads
Configflutterfire configure generates per-platform keysOne project, many apps

Flutter removes duplicate Swift/Kotlin UI work. Firebase removes running your own auth server and sync layer. Together they get you to a signed-in user with live cloud data in days, not weeks.

Prerequisites

Create the Flutter project

flutter create my_app
cd my_app
flutter pub get

Use a reverse-DNS bundle ID from day one — you will register the same ID in Firebase, Apple Developer, and Google Cloud (e.g. com.example.myapp).

Organize lib/ by feature early:

lib/
├── main.dart
├── app.dart
├── firebase_options.dart      # generated; gitignore in prod
├── core/
│   ├── router/app_router.dart
│   └── constants/google_auth_config.dart
├── data/
│   ├── models/
│   ├── repositories/
│   └── providers/
└── features/
    └── auth/
        ├── auth_repository.dart
        └── presentation/

Firebase Console setup

  1. Go to Firebase ConsoleAdd project.
  2. Register apps for iOS, Android, and optionally Web with your bundle ID / package name.
  3. Open Build → Authentication → Sign-in method and enable Email/Password, Google, and Apple.
  4. Open Build → Firestore Database → create database in production mode (add rules immediately).
  5. Note your Project ID for FlutterFire CLI.

Wire Firebase with FlutterFire CLI

dart pub global activate flutterfire_cli
flutterfire configure --project=YOUR_PROJECT_ID

Select the platforms you target. The CLI generates:

FilePurpose
lib/firebase_options.dartFirebaseOptions per platform
android/app/google-services.jsonAndroid Firebase config
ios/Runner/GoogleService-Info.plistiOS Firebase config
firebase.jsonMaps Flutter apps to Firebase

Gitignore generated secrets in team repos. Commit a firebase_options.dart.example with placeholder keys. New clones run flutterfire configure locally.

Dependencies

dependencies:
  flutter:
    sdk: flutter
  firebase_core: ^3.13.0
  firebase_auth: ^5.5.3
  cloud_firestore: ^5.6.7
  firebase_storage: ^12.4.4
  google_sign_in: ^6.3.0
  sign_in_with_apple: ^8.0.0
  crypto: ^3.0.7
  flutter_riverpod: ^2.6.1
  go_router: ^15.1.2
  shared_preferences: ^2.5.3
PackageRole
firebase_coreBootstrap all Firebase SDKs
firebase_authAuth state, credentials, sign-out
cloud_firestoreReal-time document database
google_sign_inNative Google account picker → Firebase credential
sign_in_with_appleApple ID credential → Firebase OAuth
cryptoSHA-256 nonce for Apple Sign-In
flutter_riverpodDI + reactive auth/data providers
go_routerDeclarative routing with auth redirects

Initialize Firebase in main.dart

Firebase must initialize before runApp. Wrap the app in ProviderScope for Riverpod:

import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:my_app/app.dart';
import 'package:my_app/firebase_options.dart';

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

DefaultFirebaseOptions.currentPlatform picks web, Android, iOS, or macOS options automatically.

Android platform config

google-services plugin

In android/settings.gradle.kts:

plugins {
    id("com.google.gms.google-services") version "4.3.15" apply false
}

In android/app/build.gradle.kts:

plugins {
    id("com.android.application")
    id("com.google.gms.google-services")
}

android {
    namespace = "com.example.myapp"
    defaultConfig {
        applicationId = "com.example.myapp"
    }
}

SHA-1 fingerprint (critical for Google Sign-In)

Google Sign-In on Android fails with ApiException: 10 / DEVELOPER_ERROR if Firebase does not know your signing certificate.

keytool -list -v -keystore ~/.android/debug.keystore \
  -alias androiddebugkey -storepass android -keypass android

Copy the SHA-1 into Firebase Console → Project settings → Your Android app → Add fingerprint. Add release keystore SHA-1 before shipping. Re-download google-services.json after adding fingerprints.

iOS platform config

Google Sign-In URL scheme

Add the reversed client ID from GoogleService-Info.plist to ios/Runner/Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>com.googleusercontent.apps.YOUR_IOS_CLIENT_NUM</string>
    </array>
  </dict>
</array>
<key>GIDClientID</key>
<string>YOUR_IOS_CLIENT_ID.apps.googleusercontent.com</string>

Sign in with Apple entitlement

In Xcode: Runner target → Signing & Capabilities → + Capability → Sign in with Apple. This updates ios/Runner/Runner.entitlements:

<key>com.apple.developer.applesignin</key>
<array>
  <string>Default</string>
</array>

Configure the Apple provider in Firebase Console with your Apple Services ID, team ID, and key.

Podfile

target 'Runner' do
  use_frameworks! :linkage => :static
  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end

Google Sign-In implementation

Centralize OAuth client IDs (extract from plist and json):

class GoogleAuthConfig {
  static const iosClientId =
      'YOUR_IOS_CLIENT_ID.apps.googleusercontent.com';
  static const webClientId =
      'YOUR_WEB_CLIENT_ID.apps.googleusercontent.com';
}

The web client ID is serverClientId on mobile — Firebase verifies the ID token server-side. Find it as client_type: 3 in google-services.json.

Future<void> signInWithGoogle() async {
  if (kIsWeb) {
    await _auth.signInWithPopup(GoogleAuthProvider());
    return;
  }

  final googleSignIn = GoogleSignIn(
    scopes: const ['email', 'profile'],
    clientId: defaultTargetPlatform == TargetPlatform.iOS
        ? GoogleAuthConfig.iosClientId
        : null,
    serverClientId: GoogleAuthConfig.webClientId,
  );

  try {
    await googleSignIn.signOut();
  } catch (_) {}

  final account = await googleSignIn.signIn();
  if (account == null) {
    throw FirebaseAuthException(code: 'popup-closed-by-user');
  }

  final auth = await account.authentication;
  final credential = GoogleAuthProvider.credential(
    accessToken: auth.accessToken,
    idToken: auth.idToken,
  );
  await _auth.signInWithCredential(credential);
}

Apple Sign-In implementation

Apple Sign-In is iOS/macOS only. Firebase requires a nonce: SHA-256 goes to Apple, raw nonce goes to Firebase.

import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';

Future<void> signInWithApple() async {
  if (kIsWeb || defaultTargetPlatform == TargetPlatform.android) {
    throw FirebaseAuthException(
      code: 'operation-not-allowed',
      message: 'Apple sign-in is only supported on iOS and macOS.',
    );
  }

  final rawNonce = _generateNonce();
  final nonce = sha256.convert(utf8.encode(rawNonce)).toString();

  final appleCredential = await SignInWithApple.getAppleIDCredential(
    scopes: [
      AppleIDAuthorizationScopes.email,
      AppleIDAuthorizationScopes.fullName,
    ],
    nonce: nonce,
  );

  final idToken = appleCredential.identityToken;
  if (idToken == null) {
    throw FirebaseAuthException(code: 'invalid-credential');
  }

  final oauthCredential = OAuthProvider('apple.com').credential(
    idToken: idToken,
    rawNonce: rawNonce,
  );
  await _auth.signInWithCredential(oauthCredential);
}

Show the Apple button only where supported:

bool get showAppleSignIn =>
    !kIsWeb && (Platform.isIOS || Platform.isMacOS);

Apple sends the user's name once — capture it on first sign-in and write to Firestore immediately if you need display names.

Email/password auth

Future<void> signInWithEmail(String email, String password) async {
  await _auth.signInWithEmailAndPassword(email: email, password: password);
}

Future<void> signUpWithEmail(String email, String password) async {
  await _auth.createUserWithEmailAndPassword(email: email, password: password);
}

// Password reset
await FirebaseAuth.instance.sendPasswordResetEmail(email: email);

Auth repository and error handling

Code / symptomUser message
ApiException: 10 / developer_errorAdd Android SHA-1 in Firebase Console
popup-closed-by-userSign-in was cancelled
email-already-in-useTry signing in instead
account-exists-with-different-credentialEmail linked to another provider
Apple AuthorizationErrorCode.canceledSign-in was cancelled
configuration-not-foundEnable provider in Firebase Console

A dedicated authErrorMessage(Object error) function keeps UI widgets thin — buttons catch errors and show SnackBars.

Riverpod providers for Firebase

final firebaseAuthProvider = Provider<FirebaseAuth>(
  (ref) => FirebaseAuth.instance,
);

final firestoreProvider = Provider<FirebaseFirestore>(
  (ref) => FirebaseFirestore.instance,
);

final authRepositoryProvider = Provider<AuthRepository>((ref) {
  return AuthRepository(ref.watch(firebaseAuthProvider));
});

final authUidProvider = StreamProvider<String?>((ref) {
  return ref
      .watch(firebaseAuthProvider)
      .authStateChanges()
      .map((user) => user?.uid);
});

final currentUserIdProvider = Provider<String?>((ref) {
  return ref.watch(authUidProvider).valueOrNull;
});

authStateChanges() is the single source of truth. Every Firestore provider gates on currentUserIdProvider — if UID is null, return Stream.empty().

GoRouter auth-gated routing

Do not call context.go('/') manually after sign-in. Let GoRouter redirect react to auth stream changes.

const _publicAuthPaths = ['/welcome', '/sign-in', '/sign-up'];

final routerProvider = Provider<GoRouter>((ref) {
  final authState = ref.watch(authUidProvider);
  final profileState = ref.watch(userProfileProvider);

  return GoRouter(
    initialLocation: '/welcome',
    redirect: (context, state) {
      final uid = authState.valueOrNull;
      final path = state.matchedLocation;

      if (authState.isLoading) return null;

      if (uid == null) {
        return _isPublicAuthPath(path) ? null : '/welcome';
      }

      final profile = profileState.valueOrNull;
      if (profileState.isLoading) return null;

      final target = routeAfterAuth(profile);

      if (_isPublicAuthPath(path)) {
        return path == target ? null : target;
      }

      if (target != '/') {
        final onSetup = path == target || path.startsWith('$target/');
        if (!onSetup) return target;
      }

      return null;
    },
    routes: [
      GoRoute(path: '/welcome', builder: (_, __) => const WelcomeScreen()),
      GoRoute(path: '/sign-in', builder: (_, __) => const SignInScreen()),
    ],
  );
});

Pattern: auth stream + profile stream → redirect function. UI only triggers sign-in; routing follows state.

Firestore data model

Use a user-scoped hierarchy — every document lives under the authenticated UID:

users/{uid}/profile/settings     ← single profile doc
users/{uid}/items/{itemId}       ← user-owned collection
class FirestorePaths {
  static String userRoot(String uid) => 'users/$uid';
  static String profile(String uid) => '${userRoot(uid)}/profile/settings';
  static String items(String uid) => '${userRoot(uid)}/items';
}

Subcollections under users/{uid} give one-line security rules, scoped deletes, and no accidental cross-user queries.

Security rules

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId}/{document=**} {
      allow read, write: if request.auth != null && request.auth.uid == userId;
    }
  }
}

Test in Firebase Console Rules playground. Never ship with allow read, write: if true except local emulators.

Models: fromFirestore and toFirestore

class UserProfile {
  const UserProfile({
    required this.displayName,
    this.createdAt,
    this.onboardingComplete = false,
  });

  final String displayName;
  final DateTime? createdAt;
  final bool onboardingComplete;

  factory UserProfile.fromFirestore(
    DocumentSnapshot<Map<String, dynamic>> doc,
  ) {
    final data = doc.data() ?? {};
    return UserProfile(
      displayName: data['displayName'] as String? ?? '',
      createdAt: (data['createdAt'] as Timestamp?)?.toDate(),
      onboardingComplete: data['onboardingComplete'] as bool? ?? false,
    );
  }

  Map<String, dynamic> toFirestore() {
    return {
      'displayName': displayName,
      'onboardingComplete': onboardingComplete,
      if (createdAt != null)
        'createdAt': Timestamp.fromDate(createdAt!),
    };
  }
}

Use Timestamp for dates — not ISO strings — so queries and indexes work natively.

Repository pattern with real-time streams

class ProfileRepository {
  ProfileRepository(this._firestore);
  final FirebaseFirestore _firestore;

  DocumentReference<Map<String, dynamic>> _ref(String uid) =>
      _firestore.doc(FirestorePaths.profile(uid));

  Stream<UserProfile?> watch(String uid) {
    return _ref(uid).snapshots().map((snap) {
      if (!snap.exists) return null;
      return UserProfile.fromFirestore(snap);
    });
  }

  Future<void> save(String uid, UserProfile profile) async {
    await _ref(uid).set(profile.toFirestore(), SetOptions(merge: true));
  }
}
final userProfileProvider = StreamProvider<UserProfile?>((ref) {
  final uid = ref.watch(currentUserIdProvider);
  if (uid == null) return const Stream.empty();
  return ref.watch(profileRepositoryProvider).watch(uid);
});

Widgets use ref.watch(userProfileProvider) — they rebuild when Firestore pushes changes. No manual refresh for sync.

Batch writes and atomic updates

final batch = _firestore.batch();
batch.set(itemRef, item.toFirestore());
batch.update(summaryRef, {'count': FieldValue.increment(1)});
await batch.commit();

FieldValue.increment is atomic — safe under concurrent writes. Firestore batches cap at 500 operations; paginate bulk deletes at ~400 per batch.

Post-auth profile stub

After any sign-in, ensure a Firestore profile document exists before routing home:

Future<void> ensureAuthProfileStub(WidgetRef ref) async {
  final uid = ref.read(firebaseAuthProvider).currentUser?.uid;
  if (uid == null) return;

  final repo = ref.read(profileRepositoryProvider);
  final existing = await repo.get(uid);
  if (existing != null) return;

  final user = ref.read(firebaseAuthProvider).currentUser;
  await repo.save(
    uid,
    UserProfile(
      displayName: user?.displayName ?? '',
      createdAt: DateTime.now(),
    ),
  );
  ref.invalidate(userProfileProvider);
}

Call after signInWithGoogle() succeeds. Do not navigate manually — GoRouter redirect picks up the new profile stream.

Sign out

Future<void> signOut() async {
  if (!kIsWeb) {
    await GoogleSignIn().signOut();
  }
  await _auth.signOut();
}

Missing Google sign-out causes the next sign-in to reuse the old Google account silently.

Platform differences

FeatureWebAndroidiOS
Google Sign-InsignInWithPopupgoogle_sign_in + SHA-1google_sign_in + URL scheme
Apple Sign-InNot supportedNot supportedNative + entitlements
Email/passwordYesYesYes
Firestore offlineLimitedYesYes

Test all enabled providers on real devices — simulators miss Keychain, Google Play Services, and Apple ID edge cases.

Common errors and fixes

ApiException: 10 (Google on Android)

Cause: SHA-1 not registered or wrong google-services.json. Fix: Add debug and release SHA-1. Re-download json. Uninstall and reinstall.

invalid-credential after Google Sign-In on iOS

Cause: Missing GIDClientID or URL scheme in Info.plist. Fix: Copy values from GoogleService-Info.plist.

Apple Sign-In notHandled

Cause: Sign in with Apple capability not enabled. Fix: Add capability in Xcode, rebuild.

Firestore permission-denied

Cause: User not authenticated, wrong UID path, or rules not deployed. Fix: Confirm request.auth.uid matches path. Deploy rules.

GoRouter redirect loop

Cause: Redirect fires while profile stream still loading. Fix: Return null while authState.isLoading or profileState.isLoading.

Production checklist

FAQ

Do I need a backend server?

Not for auth and CRUD. Firestore security rules enforce access. Add Cloud Functions when you need server secrets, webhooks, or trusted batch jobs.

Riverpod vs Bloc vs Provider?

Any works. Riverpod pairs cleanly with StreamProvider for Firestore and auth streams. Keep Firebase access in repositories — not in widgets.

Can I use Supabase instead of Firestore?

Yes — auth and sync patterns differ. This guide targets Firebase because Google/Apple sign-in integration is first-class and offline cache is built in.

How do I test without real Google/Apple accounts?

Use email/password auth in debug builds. Firebase Auth emulator suite supports local testing without hitting production.

Should I put Firebase keys in .env?

FlutterFire embeds keys in firebase_options.dart — they are not secret on mobile. Security comes from Firestore rules and App Check, not hidden API keys.

Flutter plus Firebase is the fastest path from zero to signed-in users with live cloud data. Nail auth and Firestore patterns first — everything else is features on top.