Authentication & Security

Firebase Auth State Management & Persistence

16 min Lesson 2 of 12

Firebase Auth State Management & Persistence

After a user signs in with Firebase Authentication, your app must know about that sign-in state everywhere — across every screen — and survive app restarts. Firebase achieves this with two complementary mechanisms: the auth state stream (authStateChanges()) and an automatically persisted, silently refreshed ID token. Understanding both allows you to build airtight, reactive route-gating without ever polling a server.

The Auth State Stream: authStateChanges()

FirebaseAuth.instance.authStateChanges() returns a Stream<User?> that emits a new event every time the signed-in user changes. The possible events are:

  • App launch — emits the persisted User object if the user was previously signed in, or null if not.
  • Sign-in — emits the new User object immediately after a successful sign-in call.
  • Sign-out — emits null when FirebaseAuth.instance.signOut() is called.
  • Token revocation / account deletion — emits null if the backend invalidates the session.
Note: There is also idTokenChanges(), which emits on every ID-token refresh (roughly every hour). Prefer authStateChanges() for route-gating; use idTokenChanges() only when you need to react to token refreshes specifically, such as re-attaching a fresh Bearer token to an HTTP client.

Gating Routes with StreamBuilder

StreamBuilder is the idiomatic Flutter widget for consuming a stream and rebuilding the UI reactively. Wrapping your top-level routing decision inside a StreamBuilder that listens to authStateChanges() gives you a single, declarative gate: unauthenticated users always see the auth flow; authenticated users always see the app.

StreamBuilder Route Gate — main.dart

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';

class AppRouter extends StatelessWidget {
  const AppRouter({super.key});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<User?>(
      stream: FirebaseAuth.instance.authStateChanges(),
      builder: (BuildContext context, AsyncSnapshot<User?> snapshot) {
        // While Firebase checks local persistence, show a splash
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const MaterialApp(
            home: Scaffold(
              body: Center(child: CircularProgressIndicator()),
            ),
          );
        }

        final User? user = snapshot.data;

        if (user == null) {
          // No authenticated user — show login
          return const MaterialApp(home: LoginScreen());
        }

        // Authenticated — show the main app
        return const MaterialApp(home: HomeScreen());
      },
    );
  }
}
Tip: Place the StreamBuilder above MaterialApp (or wrap the home widget) so that the entire navigation stack is replaced when auth state changes. This prevents stale screens from remaining on the navigator stack after sign-out.

Token Auto-Refresh and Persistence

Firebase ID tokens expire after one hour. The FlutterFire SDK refreshes them silently in the background before they expire — you never need to call a refresh method manually. The key behaviors are:

  • The SDK caches credentials in secure platform storage (Keychain on iOS/macOS, EncryptedSharedPreferences on Android, IndexedDB on the web).
  • On the next app launch, the SDK reads from cache and emits the User object via authStateChanges() before any network call completes.
  • A background refresh is then made to confirm the token is still valid and to obtain a fresh one.
  • If the refresh fails (revoked token, deleted account, no network after a long offline period), the stream emits null and the user is signed out.

Forcing a Token Refresh & Reading Claims

Future<void> refreshAndReadToken() async {
  final User? user = FirebaseAuth.instance.currentUser;

  if (user == null) return;

  // Force-refresh the ID token (bypasses the 1-hour cache)
  final String idToken = await user.getIdToken(true);

  // Read the decoded token result for custom claims
  final IdTokenResult tokenResult = await user.getIdTokenResult(true);
  final Map<String, dynamic>? claims = tokenResult.claims;

  final bool isAdmin = claims?['admin'] == true;
  debugPrint('Token refreshed. Is admin: \$isAdmin');
}

// Listen to token changes (fires on every auto-refresh too)
void listenToTokenChanges() {
  FirebaseAuth.instance.idTokenChanges().listen((User? user) {
    if (user != null) {
      debugPrint('Token changed for: \${user.email}');
    }
  });
}

Handling the Waiting State Correctly

When the StreamBuilder first subscribes, the connection state is ConnectionState.waiting and snapshot.data is null. This brief window is not the same as "no user is signed in" — the SDK has simply not yet read from local storage. Failing to handle this state correctly causes a flash of the login screen on every app launch for authenticated users. Always check snapshot.connectionState before reading snapshot.data.

Warning: Do not treat snapshot.data == null while snapshot.connectionState == ConnectionState.waiting as "signed out". Always show a loading indicator during the waiting phase. Skipping this check causes an unwanted redirect to the login screen on every cold start.

Persistence Modes (Web)

On Flutter Web, you can control how auth state is persisted using FirebaseAuth.instance.setPersistence(). The three modes are:

  • Persistence.LOCAL (default) — state survives browser restarts; stored in IndexedDB.
  • Persistence.SESSION — state survives page reloads but not new tabs or browser restarts.
  • Persistence.NONE — state is in-memory only; lost when the page is closed. Useful for public/shared computers.
Note: On mobile (Android/iOS), persistence is always LOCAL and cannot be changed via the SDK — the OS manages credential storage. The setPersistence() API is a web-only feature.

Summary

authStateChanges() provides a reactive stream that powers route-gating in Flutter apps. Wrapping your routing logic in a StreamBuilder listening to this stream gives you declarative, automatic redirection based on auth state. Firebase persists credentials natively, auto-refreshes ID tokens hourly, and will emit null if a session becomes invalid — all without any manual token management on your part. The only critical detail is correctly handling the initial ConnectionState.waiting phase to avoid a flash of the login screen on every launch.