Home Flutter How to Manage Authentication State Across Flutter Screens

How to Manage Authentication State Across Flutter Screens

4
0

Authentication becomes harder when a Flutter application grows beyond a login screen. The profile page needs the current user, protected routes need access checks, and logout must update every screen without leaving private data visible.

Passing an isLoggedIn variable between widgets rarely handles these requirements reliably.

A maintainable approach uses one shared authentication state, exposes it reactively, and lets navigation respond to changes. Screens consume that state instead of maintaining separate versions of the session.

This guide explains the architecture and demonstrates a focused implementation using Firebase Authentication, ChangeNotifier, and go_router.

Why Authentication State Should Live Above Individual Screens

Consider an application with a dashboard, account settings, and order history. If each screen independently tracks login status, signing out from settings may leave the dashboard displaying information from the previous session.

The underlying problem is duplicated ownership.

Flutter’s architecture guidance recommends separating UI concerns from data access and using repositories as sources of truth. It specifically identifies shared repositories as a suitable place for application-wide session state.

A practical division of responsibilities looks like this:

ComponentResponsibility
Authentication service or SDKCommunicates with the identity provider
Authentication repositoryExposes session changes and authentication operations
Shared session stateRepresents the current session for the UI
RouterRedirects according to session state
ScreensRender information and submit user actions

A smaller application can combine the repository and session-state responsibilities. The important requirement is that every screen observes the same instance.

Model More Than “Logged In” and “Logged Out”

A Boolean cannot distinguish an absent session from one that has not finished loading.

At minimum, authentication state should represent:

  • Initializing: The application is determining the current session.
  • Authenticated: A user session is available.
  • Unauthenticated: No user session is available.
  • Failure: Session initialization or observation encountered an error.

The initializing state prevents a returning user from briefly seeing the login screen before session restoration completes.

Login-form concerns should remain separate. A submitting indicator or incorrect-password message does not necessarily represent a change to the application-wide session.

Likewise, profile loading and onboarding completion should not automatically be treated as authentication failures.

Choose the Correct Authentication Stream

Firebase Authentication exposes several streams with different purposes:

StreamRelevant events
authStateChanges()Initial state, sign-in, and sign-out
idTokenChanges()Authentication events and token changes
userChanges()Broader user changes, including specified client-side update operations

For basic navigation between authenticated and unauthenticated screens, authStateChanges() is usually sufficient.

Applications that react to refreshed token claims may need idTokenChanges(). A profile-oriented feature may require userChanges() or its own profile repository.

These streams do not automatically notify the client about every administrative change. Firebase documents that disabling or deleting a user through administrative tools does not directly trigger them.

Create One Shared Session Controller

The following example combines session observation and authentication operations to keep the implementation compact.

It assumes Firebase has already been configured and initialized.

import 'dart:async';

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

enum SessionStatus {
  initializing,
  authenticated,
  unauthenticated,
  failure,
}

class SessionController extends ChangeNotifier {
  SessionController(this._auth) {
    _subscription = _auth.authStateChanges().listen(
      (user) {
        _user = user;
        _status = user == null
            ? SessionStatus.unauthenticated
            : SessionStatus.authenticated;
        notifyListeners();
      },
      onError: (Object error, StackTrace stackTrace) {
        _user = null;
        _status = SessionStatus.failure;
        notifyListeners();
      },
    );
  }

  final FirebaseAuth _auth;
  late final StreamSubscription<User?> _subscription;

  User? _user;
  SessionStatus _status = SessionStatus.initializing;

  User? get user => _user;
  SessionStatus get status => _status;

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

  Future<void> signOut() => _auth.signOut();

  @override
  void dispose() {
    unawaited(_subscription.cancel());
    super.dispose();
  }
}

Successful authentication operations do not manually set _user. The SDK’s session stream supplies the resulting state, avoiding a competing local version.

Login errors should be caught by the login form or its view model and converted into appropriate messages. Production implementations should also record sanitized diagnostics for session-stream failures.

Create the controller once at application scope and dispose it when its owning scope ends. Flutter’s state-management guide demonstrates how shared ChangeNotifier instances can notify dependent widgets.

Let the Router Respond to Authentication Changes

Navigating manually after every login and logout scatters authentication rules across screens.

A central redirect keeps those rules together. go_router supports top-level redirection, and its refreshListenable parameter accepts a listenable that can trigger route reevaluation.

import 'package:go_router/go_router.dart';

GoRouter createRouter(SessionController session) {
  return GoRouter(
    refreshListenable: session,
    redirect: (context, state) {
      final path = state.uri.path;

      switch (session.status) {
        case SessionStatus.initializing:
          return path == '/loading' ? null : '/loading';

        case SessionStatus.failure:
          return path == '/session-error'
              ? null
              : '/session-error';

        case SessionStatus.unauthenticated:
          return path == '/login' ? null : '/login';

        case SessionStatus.authenticated:
          final isEntryPage = path == '/login' ||
              path == '/loading' ||
              path == '/session-error';

          return isEntryPage ? '/home' : null;
      }
    },
    routes: [
      GoRoute(
        path: '/loading',
        builder: (_, __) => const LoadingScreen(),
      ),
      GoRoute(
        path: '/session-error',
        builder: (_, __) => const SessionErrorScreen(),
      ),
      GoRoute(
        path: '/login',
        builder: (_, __) => LoginScreen(session: session),
      ),
      GoRoute(
        path: '/home',
        builder: (_, __) => HomeScreen(session: session),
      ),
      GoRoute(
        path: '/profile',
        builder: (_, __) => ProfileScreen(session: session),
      ),
    ],
  );
}

The screen classes are application-specific placeholders. Create the router once alongside the controller and pass it to MaterialApp.router(routerConfig: router).

This example treats all routes except login and session-status pages as protected. Applications with registration, password recovery, or public content should explicitly allow those routes.

It also deliberately returns users to /home. Supporting deep links requires preserving the intended internal destination across initialization and login. Validate that destination before using it, and avoid arbitrary external redirects.

Make Screens Observe the Shared User

Screens should read the shared session instead of copying the user into local state.

For a small application, constructor injection and ListenableBuilder are sufficient:

ListenableBuilder(
  listenable: session,
  builder: (context, child) {
    final user = session.user;

    return Text(
      user == null ? 'Signed out' : user.email ?? 'Account',
    );
  },
)

Larger applications can expose the controller through dependency injection. The choice of Provider, Riverpod, or Bloc does not change the central requirement: one session owner, consistently observed.

Avoid broad subscriptions when a small widget only needs one field. Keep password visibility, form input, and validation messages local to the relevant feature.

Handle Session Persistence and Token Expiration Separately

Firebase Authentication persists authentication state across application restarts on Android and iOS. Applications using that SDK should not recreate persistence with an isLoggedIn preference.

A saved Boolean cannot prove that credentials remain valid.

For a custom authentication backend, credential persistence needs an appropriate secure-storage design. The shared_preferences package explicitly warns that it must not be used for critical data. It should not serve as a credential vault. Firebase ID tokens are short-lived, while refresh tokens support obtaining replacement tokens. Session revocation and refresh failure therefore require different handling from ordinary screen navigation.

For custom API clients, a reasonable strategy is to coordinate concurrent refresh attempts, retry an eligible request once after successful refresh, and transition to signed-out state when the backend confirms the session is no longer valid.

A connectivity failure alone should not erase a valid local session.

Route Guards Do Not Replace Backend Authorization

A protected Flutter route controls what the interface displays. It does not establish permission to access server data.

For Firebase-backed custom APIs, the server should verify the client’s ID token and apply authorization rules to the requested resource. Firebase also notes that standard ID-token verification does not check revocation by default; revocation checking requires additional configuration. Source: Verify ID tokens.

Authentication answers who the user is. Authorization determines whether that user may read an order, modify a record, or perform an administrative action.

A hidden button or redirected screen cannot enforce those permissions.

Logout Must Also Clear User-Specific State

Signing out should trigger more than a route change.

A session coordinator should clear or replace user-scoped caches, close subscriptions, reset drafts where appropriate, and prevent responses from the previous account from updating the new session.

For example, an order request started by user A might finish after user B signs in. Cancelling requests where possible and checking session identity before accepting results helps prevent cross-account data leakage.

The application should also verify that back navigation and nested navigators cannot redisplay private screens after logout.

These responsibilities belong in a shared lifecycle policy rather than separate logout implementations on every screen.

Test Authentication as a Lifecycle

Authentication testing should cover transitions, not just a successful login:

  • Cold start with and without a persisted session.
  • Failed login and repeated submission attempts.
  • Logout from a nested protected screen.
  • A deep link opened while signed out.
  • Account switching while requests are still running.
  • Temporary network failure during session restoration.
  • Server rejection of an expired or revoked session.

Firebase provides an Authentication emulator for testing authentication flows without relying on production accounts.

A reliable Flutter authentication architecture makes every screen respond to the same session, keeps navigation rules centralized, and preserves server-side authorization. With those responsibilities clearly assigned, adding another protected screen becomes a routing and UI task rather than another authentication implementation.

Previous articleHow to Reduce Unnecessary Widget Rebuilds in Flutter

LEAVE A REPLY

Please enter your comment!
Please enter your name here