Authentication in Flutter becomes difficult when individual screens maintain separate versions of a user’s session. A login screen may report success while the profile page shows outdated information. After logout, navigation history may expose a screen that should no longer be available.
A reliable approach uses one shared authentication state, observes session changes, and lets navigation respond consistently.
This guide explains that approach using Firebase Authentication and go_router, with practical considerations for session restoration, logout, and protected screens.
What Is Authentication State?
Authentication state represents what the application currently knows about a user’s session.
| State | Meaning | Screen behavior |
|---|---|---|
| Initializing | The session is being restored | Show a loading screen |
| Signed out | No authenticated user is available | Allow public screens |
| Signed in | An authenticated user is available | Allow permitted screens |
| Error | Session initialization or observation failed | Show recovery options |
A single isLoggedIn boolean cannot distinguish initialization from an unauthenticated session. That distinction prevents the login screen from briefly appearing while an existing session loads.
Authentication also differs from authorization. Signing in establishes identity; access to a specific resource still depends on permissions.
Keep Authentication State Above Individual Screens
A maintainable Flutter authentication architecture separates responsibilities:
- Authentication repository: Communicates with the identity provider.
- Session controller: Exposes observable authentication state.
- Router: Applies navigation rules.
- Screens: Display information and collect user input.
This aligns with Flutter’s guidance on separating UI and data responsibilities. Flutter architecture guide
Local form state should remain local. Password visibility, field validation, and button-loading indicators do not need to become application-wide session state.
Choose the Correct Firebase Authentication Stream
Firebase provides three streams with different purposes:
| Stream | What it observes |
|---|---|
authStateChanges() | Initial authentication state, sign-in, and sign-out |
idTokenChanges() | Authentication events and ID-token changes |
userChanges() | Token events and supported user-profile operations |
For basic protected navigation, authStateChanges() is usually sufficient. Applications displaying token-dependent information or profile updates may need the other streams.
Administrative changes, including remotely disabling an account, do not automatically trigger corresponding client events in every situation. Firebase authentication documentation
Create a Shared Session Controller
The following controller observes Firebase and notifies its consumers when the session changes.
Firebase must already be configured and initialized.
import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart';
enum SessionStatus {
initializing,
signedOut,
signedIn,
error,
}
class SessionController extends ChangeNotifier {
SessionController(this._auth) {
_subscription = _auth.authStateChanges().listen(
(user) {
_user = user;
_status = user == null
? SessionStatus.signedOut
: SessionStatus.signedIn;
notifyListeners();
},
onError: (Object error, StackTrace stackTrace) {
_user = null;
_status = SessionStatus.error;
notifyListeners();
},
);
}
final FirebaseAuth _auth;
late final StreamSubscription<User?> _subscription;
SessionStatus _status = SessionStatus.initializing;
User? _user;
SessionStatus get status => _status;
User? get user => _user;
Future<void> signIn(String email, String password) async {
await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
}
Future<void> signOut() => _auth.signOut();
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}
The controller does not manually mark login as successful. Firebase’s session event updates the state.
Failed sign-in attempts should produce form-level errors. They should not be confused with failures to initialize or observe the session.
ChangeNotifier supplies the notification mechanism, and its owner must dispose of it appropriately. Flutter ChangeNotifier documentation
In larger applications, placing a repository between Firebase and the controller makes authentication easier to replace and test.
Protect Screens Through Centralized Routing
Navigation rules should react to the shared session rather than being repeated inside every screen.
go_router supports redirection and a refreshListenable that can trigger route reevaluation when the controller changes. GoRouter API
The following fragment belongs inside a GoRouter configuration:
refreshListenable: session,
redirect: (context, state) {
final path = state.uri.path;
switch (session.status) {
case SessionStatus.initializing:
return path == '/loading' ? null : '/loading';
case SessionStatus.error:
return path == '/session-error'
? null
: '/session-error';
case SessionStatus.signedOut:
final isPublic =
path == '/login' || path == '/privacy';
return isPublic ? null : '/login';
case SessionStatus.signedIn:
final isSessionPage =
path == '/login' ||
path == '/loading' ||
path == '/session-error';
return isSessionPage ? '/' : null;
}
},
The application must define the referenced routes. The error screen should offer a way to retry session initialization.
Returning null permits navigation; returning a location requests a redirect. Avoid redirecting repeatedly to the same destination. go_router redirection guide
Create the controller and router once in an application-level owner. Recreating them inside build() can reset routing state or create unnecessary subscriptions.
Preserve protected deep links
This simplified configuration returns users to / after login or initialization.
For a destination such as /orders/123, preserve the intended internal URI before redirecting. Restore it after authentication, provided it remains valid and permitted.
Return destinations should be restricted to approved application routes.
Share the Same Session Across Screens
Screens can observe the controller through constructor injection or the application’s existing dependency-injection system.
ListenableBuilder(
listenable: session,
builder: (context, child) {
return Text(
session.user?.email ?? 'No active session',
);
},
)
The important detail is that every consumer receives the same controller instance.
Applications using Riverpod or Bloc can implement this pattern through their existing state-management approach. Introducing another library is unnecessary when the current setup already supports shared, observable state.
Profile information may require separate observation. A display-name change does not necessarily represent a sign-in or sign-out event. Firebase documents profile updates and user reload behavior separately. Firebase user management
Restore Sessions Without Duplicating Login Flags
Firebase supports session persistence across application restarts, with configurable persistence behavior on the web. A separately stored isLoggedIn flag can become inconsistent with the actual session. Firebase persistence documentation
The initializing state gives the authentication layer time to restore the session before navigation decisions occur.
With a custom backend, restoration should include the appropriate credential validation or refresh process. Finding a stored token alone should not establish that the session remains valid.
Make Logout Clear User-Specific State
Logout affects more than the visible screen. Applications should also invalidate or dispose of:
- User-specific subscriptions.
- Cached account and profile data.
- Selected organizations or workspaces.
- Pending requests associated with the previous session.
For example, a request started by user A could finish after user B signs in. Its result must not update user B’s interface.
User-scoped repositories or session identifiers help prevent this cross-session leakage.
Protected navigation should also remain enforced when a user presses Back, opens browser history, or follows a deep link.
Enforce Authorization on the Backend
Flutter route guards control presentation. They do not secure an API or database.
Backend services must validate credentials and enforce permissions independently. Local sign-out also differs from revoking sessions across devices. Firebase documents token lifetimes and server-side revocation handling in its session-management guide. Firebase session management
An access-denied response should not automatically log out a valid user. Authentication failure and insufficient permission require different handling.
Test Authentication Transitions
A useful test plan covers more than successful login:
| Scenario | Expected result |
|---|---|
| Startup without a session | Loading, then login |
| Startup with a restored session | Loading, then permitted content |
| Incorrect credentials | Form error without authenticated navigation |
| Logout from a nested screen | Protected content becomes inaccessible |
| Protected deep link | Authentication followed by a validated destination |
| Session observation failure | Recovery screen |
| User switch during a request | Previous session’s response is discarded |
Controller tests can use a fake authentication repository. Widget and routing tests should verify that navigation matches session state.
Frequently Asked Questions
Is setState() enough for authentication?
It works for local form behavior. Authentication shared across screens needs a common owner so that session information and navigation stay synchronized.
Can StreamBuilder manage authentication?
Yes, especially for a simple root authentication gate. Flutter recommends obtaining the stream before build() rather than creating it during every rebuild. StreamBuilder documentation
The application must still handle navigation stacks correctly. Replacing a home widget alone may leave separately pushed routes visible.
Should the login button navigate after sign-in?
When the router observes authentication state, the session change can drive navigation. The button can focus on submitting credentials and displaying errors.
What is the key architectural principle?
Every screen should derive authentication information from one observable source. This keeps sign-in, restoration, protected navigation, and logout consistent.


















