FlutterFirebaseAuthenticationFirestoreCloud FunctionsMobile DevelopmentGuideTutorial

Flutter + Firebase: Complete Tutorial for 2026 (Auth, Firestore, Functions, Deploy)

Adrijan Omičević··13 min read
Share

# What You’ll Build (and Why It Matters)#

This flutter firebase tutorial walks you from a blank Flutter project to a deployed app with Firebase Authentication, Firestore, and Cloud Functions. You’ll end with an app that can sign users in, store per-user data securely, and run server-side logic you can’t trust to the client.

Firebase is popular because it removes months of backend work, but production apps still fail on basics like security rules, indexing, and data modeling. This guide focuses on the practical parts that matter for real deployments, not just “it runs on my phone”.

If you’re evaluating cross-platform options, compare the tradeoffs in Flutter vs React Native (2026). If you need a team to ship faster, Samioda builds production apps with Flutter and automation stacks: mobile & web development.

# Prerequisites (Tools + Accounts)#

RequirementVersion (recommended)Notes
Flutter SDKStable channel (latest)flutter doctor should be green
DartBundled with FlutterNo separate install needed
Android Studio / XcodeLatest stableFor emulators/simulators
Firebase accountGoogle accountYou’ll create a project + apps
Node.js18+ LTSNeeded for Firebase CLI + Functions
Firebase CLILatestDeploy Functions, configure hosting (optional)

Install Firebase CLI:

Bash
npm i -g firebase-tools
firebase login

Create and verify your Flutter app:

Bash
flutter create flutter_firebase_2026
cd flutter_firebase_2026
flutter run

ℹ️ Note: This tutorial uses Flutter’s modern Firebase approach via flutterfire CLI to generate platform configs. That avoids manual config mistakes and keeps secrets out of source control.

# Step 1: Create Firebase Project + Register Apps#

1) Create the Firebase project#

  1. 1
    Go to Firebase Console → Add project
  2. 2
    Choose a project name (e.g., flutter-firebase-2026)
  3. 3
    Enable Google Analytics only if you need it now (you can add later)

2) Register Android and iOS apps#

In the Firebase project:

  • Add an Android app with package name like com.example.flutter_firebase_2026
  • Add an iOS app with bundle ID like com.example.flutterFirebase2026

For Android, set your applicationId in android/app/build.gradle (if you want a custom ID). For iOS, set your bundle ID in Xcode (Runner target settings) or in your project template.

3) Use FlutterFire CLI to configure#

Install FlutterFire CLI:

Bash
dart pub global activate flutterfire_cli

Run configuration:

Bash
flutterfire configure

This generates lib/firebase_options.dart and adds native config files in the right places.

# Step 2: Add Firebase Dependencies to Flutter#

Add packages:

Bash
flutter pub add firebase_core firebase_auth cloud_firestore
flutter pub add flutter_riverpod

Initialize Firebase in main.dart:

Dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
 
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const MyApp());
}
 
class MyApp extends StatelessWidget {
  const MyApp({super.key});
 
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(body: Center(child: Text('Firebase ready'))),
    );
  }
}

At this point, run:

Bash
flutter run

If you see “Firebase ready”, you’ve completed the baseline setup.

💡 Tip: Add a “smoke test” at this stage (one device + one emulator). It’s cheaper to fix native config issues now than after you add auth, rules, and functions.

# Step 3: Firebase Authentication (Email/Password)#

Authentication is your foundation for access control. Without auth, Firestore rules often become overly permissive “just for testing” and accidentally ship to production.

1) Enable sign-in methods#

Firebase Console → AuthenticationSign-in method:

  • Enable Email/Password

2) Implement a minimal auth service#

Create lib/auth/auth_service.dart:

Dart
import 'package:firebase_auth/firebase_auth.dart';
 
class AuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;
 
  Stream<User?> authStateChanges() => _auth.authStateChanges();
 
  Future<UserCredential> signUp(String email, String password) {
    return _auth.createUserWithEmailAndPassword(
      email: email,
      password: password,
    );
  }
 
  Future<UserCredential> signIn(String email, String password) {
    return _auth.signInWithEmailAndPassword(
      email: email,
      password: password,
    );
  }
 
  Future<void> signOut() => _auth.signOut();
}

3) Build simple UI: sign-in / sign-up#

Create lib/auth/auth_screen.dart:

Dart
import 'package:flutter/material.dart';
import 'auth_service.dart';
 
class AuthScreen extends StatefulWidget {
  const AuthScreen({super.key});
 
  @override
  State<AuthScreen> createState() => _AuthScreenState();
}
 
class _AuthScreenState extends State<AuthScreen> {
  final _auth = AuthService();
  final _email = TextEditingController();
  final _password = TextEditingController();
  String? _error;
 
  Future<void> _run(Future<void> Function() action) async {
    setState(() => _error = null);
    try {
      await action();
    } catch (e) {
      setState(() => _error = e.toString());
    }
  }
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sign in')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(controller: _email, decoration: const InputDecoration(labelText: 'Email')),
            TextField(
              controller: _password,
              decoration: const InputDecoration(labelText: 'Password'),
              obscureText: true,
            ),
            const SizedBox(height: 12),
            if (_error != null) Text(_error!, style: const TextStyle(color: Colors.red)),
            const SizedBox(height: 12),
            Row(
              children: [
                Expanded(
                  child: ElevatedButton(
                    onPressed: () => _run(() async {
                      await _auth.signIn(_email.text.trim(), _password.text.trim());
                    }),
                    child: const Text('Sign in'),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: OutlinedButton(
                    onPressed: () => _run(() async {
                      await _auth.signUp(_email.text.trim(), _password.text.trim());
                    }),
                    child: const Text('Sign up'),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

4) Route users based on auth state#

Replace home: in MyApp with an auth gate.

Dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'auth/auth_screen.dart';
 
class AuthGate extends StatelessWidget {
  const AuthGate({super.key});
 
  @override
  Widget build(BuildContext context) {
    return StreamBuilder<User?>(
      stream: FirebaseAuth.instance.authStateChanges(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Scaffold(body: Center(child: CircularProgressIndicator()));
        }
        if (snapshot.data == null) return const AuthScreen();
        return const Scaffold(body: Center(child: Text('Logged in')));
      },
    );
  }
}

And set:

Dart
home: const AuthGate(),

⚠️ Warning: Don’t show “logged in” content before authStateChanges() resolves. Otherwise you’ll get flicker and, worse, briefly render screens that assume a user exists.

# Step 4: Firestore Data Model + Security Rules#

Firestore is flexible, but flexibility creates messy schemas. Start with a clean, minimal model.

Data model: per-user “todos”#

We’ll store todos under a user:

  • users/{uid} (profile)
  • users/{uid}/todos/{todoId} (items)

This structure makes security rules straightforward: a user can access only their own subtree.

Collection/DocPathExample fields
User profileusers/{uid}email, createdAt
Todo itemusers/{uid}/todos/{todoId}title, done, createdAt, updatedAt

1) Create Firestore database#

Firebase Console → Firestore Database → Create database. Choose a region close to your users (latency matters; even 80–150ms per request adds up).

2) Set Firestore security rules (production baseline)#

Firestore → Rules:

Txt
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
 
    function isSignedIn() {
      return request.auth != null;
    }
 
    match /users/{userId} {
      allow read, write: if isSignedIn() && request.auth.uid == userId;
 
      match /todos/{todoId} {
        allow read, write: if isSignedIn() && request.auth.uid == userId;
      }
    }
  }
}

This locks data to the authenticated user. It’s not “enterprise RBAC”, but it prevents the most common Firebase breach: global read access.

3) Build Firestore CRUD service#

Create lib/data/todo_service.dart:

Dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
 
class TodoService {
  final _db = FirebaseFirestore.instance;
 
  String get _uid => FirebaseAuth.instance.currentUser!.uid;
 
  CollectionReference<Map<String, dynamic>> get _todos =>
      _db.collection('users').doc(_uid).collection('todos');
 
  Stream<QuerySnapshot<Map<String, dynamic>>> watchTodos() {
    return _todos.orderBy('createdAt', descending: true).snapshots();
  }
 
  Future<void> addTodo(String title) {
    final now = FieldValue.serverTimestamp();
    return _todos.add({
      'title': title,
      'done': false,
      'createdAt': now,
      'updatedAt': now,
    });
  }
 
  Future<void> toggleDone(String id, bool done) {
    return _todos.doc(id).update({
      'done': done,
      'updatedAt': FieldValue.serverTimestamp(),
    });
  }
 
  Future<void> deleteTodo(String id) => _todos.doc(id).delete();
}

🎯 Key Takeaway: Use serverTimestamp() for audit fields. Client timestamps drift (and can be manipulated), which breaks sorting and can cause confusing UI bugs.

4) Build the todos screen#

Create lib/todos/todos_screen.dart:

Dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import '../data/todo_service.dart';
import 'package:firebase_auth/firebase_auth.dart';
 
class TodosScreen extends StatefulWidget {
  const TodosScreen({super.key});
 
  @override
  State<TodosScreen> createState() => _TodosScreenState();
}
 
class _TodosScreenState extends State<TodosScreen> {
  final _service = TodoService();
  final _controller = TextEditingController();
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Todos'),
        actions: [
          IconButton(
            onPressed: () => FirebaseAuth.instance.signOut(),
            icon: const Icon(Icons.logout),
          ),
        ],
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(12),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: const InputDecoration(hintText: 'Add a todo...'),
                  ),
                ),
                const SizedBox(width: 8),
                ElevatedButton(
                  onPressed: () async {
                    final text = _controller.text.trim();
                    if (text.isEmpty) return;
                    _controller.clear();
                    await _service.addTodo(text);
                  },
                  child: const Text('Add'),
                ),
              ],
            ),
          ),
          Expanded(
            child: StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
              stream: _service.watchTodos(),
              builder: (context, snapshot) {
                if (!snapshot.hasData) {
                  return const Center(child: CircularProgressIndicator());
                }
                final docs = snapshot.data!.docs;
                if (docs.isEmpty) return const Center(child: Text('No todos yet.'));
                return ListView.builder(
                  itemCount: docs.length,
                  itemBuilder: (context, i) {
                    final d = docs[i];
                    final data = d.data();
                    final title = (data['title'] ?? '') as String;
                    final done = (data['done'] ?? false) as bool;
 
                    return Dismissible(
                      key: ValueKey(d.id),
                      background: Container(color: Colors.red),
                      onDismissed: (_) => _service.deleteTodo(d.id),
                      child: CheckboxListTile(
                        value: done,
                        title: Text(title),
                        onChanged: (v) => _service.toggleDone(d.id, v ?? false),
                      ),
                    );
                  },
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

Then update the auth gate to show TodosScreen when logged in.

# Step 5: Firestore Indexes + Performance Basics#

Firestore performance issues usually come from:

  • unbounded queries (no limits),
  • missing composite indexes,
  • chatty UI (too many listeners),
  • oversized documents.

Add query limits#

If your list can grow, add .limit(50) and paginate later.

Dart
return _todos
  .orderBy('createdAt', descending: true)
  .limit(50)
  .snapshots();

Know when you need an index#

If you query with multiple where clauses + orderBy, Firestore will often require a composite index. In production, that becomes a runtime error unless you created the index.

Query patternUsually needs composite index?Example
orderBy(createdAt)NoRecent items
where(done == false)NoFilter only
where(done == false).orderBy(createdAt)Often yesFilter + sort
where(status in [...]).orderBy(priority)Often yesComplex lists

💡 Tip: When Firestore throws an index error, it includes a direct console link to create the required index. Click it, create the index, and commit the query shape into your codebase so it doesn’t change unexpectedly.

# Step 6: Cloud Functions (Server-Side Logic You Can Trust)#

Firestore rules control access, but they’re not a full programming environment. Cloud Functions are where you put privileged logic like:

  • assigning roles,
  • writing audit logs,
  • enforcing cross-document constraints,
  • calling external APIs.

1) Initialize Functions#

In your project root:

Bash
firebase init functions

Choose:

  • Language: JavaScript or TypeScript (TypeScript recommended)
  • Use ESLint: optional
  • Install dependencies: yes

If you’re using TypeScript, you’ll deploy from functions/.

2) Example function: create user profile on signup#

Create an auth trigger that writes users/{uid} when a user registers.

functions/src/index.ts (TypeScript):

TypeScript
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
 
admin.initializeApp();
 
export const onUserCreate = functions.auth.user().onCreate(async (user) => {
  const uid = user.uid;
  const email = user.email ?? null;
 
  await admin.firestore().collection("users").doc(uid).set({
    email,
    createdAt: admin.firestore.FieldValue.serverTimestamp(),
  }, { merge: true });
});

Deploy:

Bash
firebase deploy --only functions

3) Example callable function: set “admin” claim (restricted)#

Don’t let clients assign roles. This function can be used only by existing admins.

TypeScript
export const setAdminClaim = functions.https.onCall(async (data, context) => {
  const callerUid = context.auth?.uid;
  if (!callerUid) throw new functions.https.HttpsError("unauthenticated", "Sign in required.");
 
  const caller = await admin.auth().getUser(callerUid);
  const isAdmin = caller.customClaims?.admin === true;
  if (!isAdmin) throw new functions.https.HttpsError("permission-denied", "Admin only.");
 
  const targetUid = data.uid as string;
  await admin.auth().setCustomUserClaims(targetUid, { admin: true });
 
  return { ok: true };
});

On the Flutter side, you’d call it with cloud_functions package when needed (e.g., internal admin app).

⚠️ Warning: Callable functions are not automatically “secure”. Always validate context.auth and authorize the caller. Many Firebase incidents are caused by functions that assume “the app UI prevents abuse”.

# Step 7: App Hardening (Rules, Validation, Error Handling)#

1) Add basic validation in Firestore rules#

Prevent empty titles and oversized strings (cost and UX).

Txt
match /users/{userId}/todos/{todoId} {
  allow create: if isSignedIn()
    && request.auth.uid == userId
    && request.resource.data.title is string
    && request.resource.data.title.size() > 0
    && request.resource.data.title.size() <= 140;
 
  allow update, delete, read: if isSignedIn() && request.auth.uid == userId;
}

2) Avoid storing secrets in the app#

Anything in the client can be extracted. Use Functions for:

  • API keys requiring secrecy,
  • billing operations,
  • user-to-user permission checks.

3) Centralize Firebase error mapping#

FirebaseAuth errors are not user-friendly. Map them.

Dart
String authMessage(Object e) {
  final s = e.toString();
  if (s.contains('wrong-password')) return 'Wrong password.';
  if (s.contains('user-not-found')) return 'No user found for that email.';
  if (s.contains('email-already-in-use')) return 'Email already in use.';
  return 'Something went wrong. Please try again.';
}

# Step 8: Deploy (Functions + Production Checklist)#

Flutter apps ship via App Store / Play Store, but your Firebase backend must also be “deployed” and locked down.

1) Deploy Cloud Functions#

You already used:

Bash
firebase deploy --only functions

2) Confirm rules are not in “test mode”#

In Firestore rules, avoid:

  • allow read, write: if true;

3) Verify billing plan requirements#

Some Firebase features (and usage levels) require Blaze plan. Don’t discover this during launch week.

FeatureTypically requires Blaze?Why it matters
Firestore basicNot alwaysFree tier exists, but limits apply
Cloud Functions (some triggers/egress)OftenExternal network calls and scale
AuthenticationUsually noBut SMS/phone has constraints
Scheduled FunctionsOftenCommon for batch jobs

4) Add basic monitoring#

  • Firebase Console → Functions logs
  • Consider exporting logs to BigQuery for larger apps
  • Set alerting on error spikes

ℹ️ Note: Google’s DORA research consistently shows that teams with strong observability and fast feedback loops ship more frequently with lower change failure rates. In practice: logs and alerts reduce “silent failures” that drain weeks.

# Common Pitfalls (and How to Avoid Them)#

  1. 1
    Leaving Firestore in test mode — Lock rules early and build your UI around permission errors.
  2. 2
    Flat collections with userId fields — It’s workable, but easier to get wrong. Prefer users/{uid}/... for per-user data.
  3. 3
    Relying on the client for security — Anything the client can do, an attacker can do. Use rules + functions.
  4. 4
    No indexes strategy — If you add “filters + sorting” screens, plan and document query shapes so indexes don’t surprise you.
  5. 5
    Oversized documents — Keep documents small; store large blobs in Cloud Storage and reference URLs in Firestore.

For teams that want production help (architecture, rules review, CI/CD, cost controls), see Samioda mobile & web development. If you’re still choosing a stack, review Flutter vs React Native (2026).

# Key Takeaways#

  • Use FlutterFire CLI to configure Firebase correctly across platforms and avoid fragile manual setup.
  • Build auth first, then design Firestore around security rules (e.g., users/{uid}/...) so access control stays simple.
  • Prefer serverTimestamp() for createdAt/updatedAt to prevent sorting bugs and tampering.
  • Use Cloud Functions for privileged logic (roles, integrations, integrity checks), and always validate context.auth.
  • Add limits, indexes, and logging early; most Firebase “scale issues” are query + rules + observability issues.

# Conclusion#

You now have a working Flutter + Firebase app with Authentication, Firestore CRUD, and Cloud Functions—enough to ship an MVP and evolve it safely. The next step is to harden rules, document your query patterns, add monitoring, and plan for pagination and roles as the product grows.

If you want Samioda to accelerate delivery or review your Firebase security and architecture before launch, contact us via mobile & web development.

FAQ

Share
A
Adrijan OmičevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.