flutter
/

BLoC Session Management: Authentication, Tokens & Persistent Sessions

Last Sync: Today

On this page

11
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC Session Management: Authentication, Tokens & Persistent Sessions

What is Session Management in BLoC?

Session management in BLoC refers to handling user authentication state, tokens, and session lifecycle using the BLoC pattern. It involves storing authentication tokens securely, persisting the session across app restarts, managing token refresh, and providing a smooth login/logout experience. A well-implemented session management system ensures that users stay logged in until they explicitly log out or their session expires.

Why Use BLoC for Session Management?

  • Centralized State – Authentication state is managed in one place, accessible anywhere in the app.
  • Persistence – With hydrated_bloc, session state survives app restarts.
  • Security – Tokens can be stored in secure storage (e.g., flutter_secure_storage) and accessed only via the bloc.
  • Predictable Flow – Events like Login, Logout, RefreshToken lead to clear state transitions.
  • Separation of Concerns – UI does not directly handle tokens or API calls.

Setting Up Dependencies

Add the necessary packages to your pubspec.yaml:

YAMLRead-only
1
dependencies:
  flutter_bloc: ^8.1.5
  hydrated_bloc: ^9.1.5
  flutter_secure_storage: ^9.2.2
  equatable: ^2.0.5
  dio: ^5.4.0
  path_provider: ^2.1.3

Defining Authentication State and Events

First, define the state that represents the authentication status. Use Equatable for value equality and prepare for JSON serialization if you plan to persist using hydrated_bloc.

DARTRead-only
1
part of 'auth_bloc.dart';

enum AuthStatus { unknown, authenticated, unauthenticated }

class AuthState extends Equatable {
  final AuthStatus status;
  final String? userId;
  final String? accessToken;
  final String? refreshToken;

  const AuthState({
    this.status = AuthStatus.unknown,
    this.userId,
    this.accessToken,
    this.refreshToken,
  });

  AuthState copyWith({
    AuthStatus? status,
    String? userId,
    String? accessToken,
    String? refreshToken,
  }) {
    return AuthState(
      status: status ?? this.status,
      userId: userId ?? this.userId,
      accessToken: accessToken ?? this.accessToken,
      refreshToken: refreshToken ?? this.refreshToken,
    );
  }

  @override
  List<Object?> get props => [status, userId, accessToken, refreshToken];

  // JSON serialization for hydrated_bloc
  Map<String, dynamic> toJson() => {
    'status': status.index,
    'userId': userId,
    'accessToken': accessToken,
    'refreshToken': refreshToken,
  };

  factory AuthState.fromJson(Map<String, dynamic> json) {
    return AuthState(
      status: AuthStatus.values[json['status'] as int],
      userId: json['userId'] as String?,
      accessToken: json['accessToken'] as String?,
      refreshToken: json['refreshToken'] as String?,
    );
  }
}

abstract class AuthEvent extends Equatable {
  const AuthEvent();

  @override
  List<Object?> get props => [];
}

class AppStarted extends AuthEvent {}

class LoggedIn extends AuthEvent {
  final String userId;
  final String accessToken;
  final String refreshToken;

  const LoggedIn({required this.userId, required this.accessToken, required this.refreshToken});

  @override
  List<Object?> get props => [userId, accessToken, refreshToken];
}

class LoggedOut extends AuthEvent {}

class TokenRefreshed extends AuthEvent {
  final String newAccessToken;
  final String newRefreshToken;

  const TokenRefreshed(this.newAccessToken, this.newRefreshToken);

  @override
  List<Object?> get props => [newAccessToken, newRefreshToken];
}

Building a Hydrated Auth Bloc

The auth bloc will extend HydratedBloc to persist the session across app restarts. It will also use flutter_secure_storage to securely store tokens (though we can rely on the bloc's persisted state, we might also store tokens separately for API interceptor).

DARTRead-only
1
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class AuthBloc extends HydratedBloc<AuthEvent, AuthState> {
  final FlutterSecureStorage secureStorage;
  final AuthRepository authRepository;

  AuthBloc({required this.secureStorage, required this.authRepository}) : super(const AuthState()) {
    on<AppStarted>(_onAppStarted);
    on<LoggedIn>(_onLoggedIn);
    on<LoggedOut>(_onLoggedOut);
    on<TokenRefreshed>(_onTokenRefreshed);
  }

  Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
    // Check if we have a persisted state already from hydrated_bloc
    // The state is automatically restored from storage.
    // But we also need to verify if the token is still valid with the backend?
    if (state.status == AuthStatus.authenticated && state.accessToken != null) {
      // Optionally validate token with a ping to backend
      final isValid = await authRepository.validateToken(state.accessToken!);
      if (!isValid) {
        // Try to refresh token if we have a refresh token
        if (state.refreshToken != null) {
          try {
            final newTokens = await authRepository.refreshToken(state.refreshToken!);
            add(TokenRefreshed(newTokens.accessToken, newTokens.refreshToken));
          } catch (_) {
            // Refresh failed, log out
            add(LoggedOut());
          }
        } else {
          add(LoggedOut());
        }
      } else {
        emit(state.copyWith(status: AuthStatus.authenticated));
      }
    } else {
      emit(state.copyWith(status: AuthStatus.unauthenticated));
    }
  }

  Future<void> _onLoggedIn(LoggedIn event, Emitter<AuthState> emit) async {
    await secureStorage.write(key: 'access_token', value: event.accessToken);
    await secureStorage.write(key: 'refresh_token', value: event.refreshToken);
    emit(state.copyWith(
      status: AuthStatus.authenticated,
      userId: event.userId,
      accessToken: event.accessToken,
      refreshToken: event.refreshToken,
    ));
  }

  Future<void> _onLoggedOut(LoggedOut event, Emitter<AuthState> emit) async {
    await secureStorage.delete(key: 'access_token');
    await secureStorage.delete(key: 'refresh_token');
    emit(const AuthState(status: AuthStatus.unauthenticated));
  }

  Future<void> _onTokenRefreshed(TokenRefreshed event, Emitter<AuthState> emit) async {
    await secureStorage.write(key: 'access_token', value: event.newAccessToken);
    await secureStorage.write(key: 'refresh_token', value: event.newRefreshToken);
    emit(state.copyWith(
      accessToken: event.newAccessToken,
      refreshToken: event.newRefreshToken,
    ));
  }

  @override
  AuthState fromJson(Map<String, dynamic> json) {
    return AuthState.fromJson(json);
  }

  @override
  Map<String, dynamic> toJson(AuthState state) {
    return state.toJson();
  }
}

Setting Up the Bloc Provider and Initialization

Initialize HydratedBloc and provide the auth bloc to the widget tree. Also, start the app with AppStarted event.

DARTRead-only
1
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final storage = await HydratedStorage.build(
    storageDirectory: await getApplicationDocumentsDirectory(),
  );
  HydratedBloc.storage = storage;

  final secureStorage = FlutterSecureStorage();
  final authRepository = AuthRepository(); // implement your own

  runApp(MyApp(
    authBloc: AuthBloc(secureStorage: secureStorage, authRepository: authRepository),
  ));
}

class MyApp extends StatelessWidget {
  final AuthBloc authBloc;

  const MyApp({required this.authBloc});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider.value(
        value: authBloc..add(AppStarted()),
        child: HomePage(),
      ),
    );
  }
}

Protecting Routes with BlocListener

Use BlocListener to listen to authentication state changes and redirect accordingly.

DARTRead-only
1
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocListener<AuthBloc, AuthState>(
      listener: (context, state) {
        if (state.status == AuthStatus.unauthenticated) {
          Navigator.pushReplacementNamed(context, '/login');
        } else if (state.status == AuthStatus.authenticated) {
          Navigator.pushReplacementNamed(context, '/dashboard');
        }
      },
      child: Scaffold(
        body: Center(child: CircularProgressIndicator()),
      ),
    );
  }
}

Token Refresh in API Interceptor

When using an HTTP client like Dio, you can add an interceptor that automatically refreshes the token when a 401 response is received. This interceptor can access the auth bloc or the secure storage to get the current tokens.

DARTRead-only
1
class AuthInterceptor extends Interceptor {
  final AuthBloc authBloc;
  final Dio dio;

  AuthInterceptor(this.authBloc, this.dio);

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = authBloc.state.accessToken;
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(DioError err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      // Attempt to refresh token
      try {
        final newTokens = await authBloc.authRepository.refreshToken(authBloc.state.refreshToken!);
        authBloc.add(TokenRefreshed(newTokens.accessToken, newTokens.refreshToken));
        // Retry the original request with new token
        final opts = err.requestOptions;
        opts.headers['Authorization'] = 'Bearer ${newTokens.accessToken}';
        final response = await dio.fetch(opts);
        return handler.resolve(response);
      } catch (_) {
        // Refresh failed, log out
        authBloc.add(LoggedOut());
        return handler.reject(err);
      }
    }
    handler.next(err);
  }
}

Best Practices

  • Store tokens securely – Use flutter_secure_storage on mobile, or shared_preferences with encryption on web.
  • Use hydrated_bloc for session state – It automatically persists and restores the state across app launches.
  • Validate tokens on app start – Don't assume a persisted token is still valid; verify with a lightweight API call or check expiration.
  • Implement token refresh gracefully – Use an interceptor to handle 401 responses and retry requests.
  • Clear sensitive data on logout – Delete tokens from secure storage and reset bloc state.
  • Separate authentication from other business logic – Keep the auth bloc focused only on authentication events.
  • Use Equatable for state comparisons – Prevents unnecessary rebuilds.
  • Test offline scenarios – Ensure the app behaves correctly when no network is available during validation.

Common Mistakes

  • ❌ Storing tokens in plain text – Use secure storage, never SharedPreferences for sensitive data.
  • ❌ Not handling token expiration – Leads to failed requests and poor user experience.
  • ❌ Calling BlocProvider.of<AuthBloc>(context) inside build without checking existence – Might throw if not provided.
  • ❌ Forgetting to add AppStarted event – The app will not initialize the session correctly.
  • ❌ Using HydratedBloc without initializing storage – Results in runtime errors.
  • ❌ Not handling concurrent refresh requests – Multiple 401 responses could trigger multiple refresh attempts; use a flag to prevent duplicate refreshes.

Conclusion

Session management with BLoC provides a robust and scalable way to handle authentication in Flutter apps. By combining hydrated_bloc for persistence, flutter_secure_storage for token security, and interceptors for automatic token refresh, you can create a seamless and secure user experience. This pattern keeps your code organized, testable, and maintainable.

Try it yourself

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

// --- Auth State & Events ---
part of 'auth_bloc.dart';

enum AuthStatus { unknown, authenticated, unauthenticated }

class AuthState extends Equatable {
  final AuthStatus status;
  final String? userId;
  final String? accessToken;
  final String? refreshToken;

  const AuthState({
    this.status = AuthStatus.unknown,
    this.userId,
    this.accessToken,
    this.refreshToken,
  });

  AuthState copyWith({
    AuthStatus? status,
    String? userId,
    String? accessToken,
    String? refreshToken,
  }) {
    return AuthState(
      status: status ?? this.status,
      userId: userId ?? this.userId,
      accessToken: accessToken ?? this.accessToken,
      refreshToken: refreshToken ?? this.refreshToken,
    );
  }

  @override
  List<Object?> get props => [status, userId, accessToken, refreshToken];

  Map<String, dynamic> toJson() => {
    'status': status.index,
    'userId': userId,
    'accessToken': accessToken,
    'refreshToken': refreshToken,
  };

  factory AuthState.fromJson(Map<String, dynamic> json) {
    return AuthState(
      status: AuthStatus.values[json['status'] as int],
      userId: json['userId'] as String?,
      accessToken: json['accessToken'] as String?,
      refreshToken: json['refreshToken'] as String?,
    );
  }
}

abstract class AuthEvent extends Equatable {
  const AuthEvent();

  @override
  List<Object?> get props => [];
}

class AppStarted extends AuthEvent {}

class LoggedIn extends AuthEvent {
  final String userId;
  final String accessToken;
  final String refreshToken;

  const LoggedIn({required this.userId, required this.accessToken, required this.refreshToken});

  @override
  List<Object?> get props => [userId, accessToken, refreshToken];
}

class LoggedOut extends AuthEvent {}

class TokenRefreshed extends AuthEvent {
  final String newAccessToken;
  final String newRefreshToken;

  const TokenRefreshed(this.newAccessToken, this.newRefreshToken);

  @override
  List<Object?> get props => [newAccessToken, newRefreshToken];
}

// --- Auth Bloc ---
class AuthBloc extends HydratedBloc<AuthEvent, AuthState> {
  final FlutterSecureStorage secureStorage;
  final AuthRepository authRepository;

  AuthBloc({required this.secureStorage, required this.authRepository}) : super(const AuthState()) {
    on<AppStarted>(_onAppStarted);
    on<LoggedIn>(_onLoggedIn);
    on<LoggedOut>(_onLoggedOut);
    on<TokenRefreshed>(_onTokenRefreshed);
  }

  Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
    if (state.status == AuthStatus.authenticated && state.accessToken != null) {
      // For demo, we assume token is valid if we have it
      emit(state.copyWith(status: AuthStatus.authenticated));
    } else {
      emit(state.copyWith(status: AuthStatus.unauthenticated));
    }
  }

  Future<void> _onLoggedIn(LoggedIn event, Emitter<AuthState> emit) async {
    await secureStorage.write(key: 'access_token', value: event.accessToken);
    await secureStorage.write(key: 'refresh_token', value: event.refreshToken);
    emit(state.copyWith(
      status: AuthStatus.authenticated,
      userId: event.userId,
      accessToken: event.accessToken,
      refreshToken: event.refreshToken,
    ));
  }

  Future<void> _onLoggedOut(LoggedOut event, Emitter<AuthState> emit) async {
    await secureStorage.delete(key: 'access_token');
    await secureStorage.delete(key: 'refresh_token');
    emit(const AuthState(status: AuthStatus.unauthenticated));
  }

  Future<void> _onTokenRefreshed(TokenRefreshed event, Emitter<AuthState> emit) async {
    await secureStorage.write(key: 'access_token', value: event.newAccessToken);
    await secureStorage.write(key: 'refresh_token', value: event.newRefreshToken);
    emit(state.copyWith(
      accessToken: event.newAccessToken,
      refreshToken: event.newRefreshToken,
    ));
  }

  @override
  AuthState fromJson(Map<String, dynamic> json) => AuthState.fromJson(json);

  @override
  Map<String, dynamic> toJson(AuthState state) => state.toJson();
}

// Mock repository for demo
class AuthRepository {
  Future<bool> login(String email, String password) async {
    // Simulate API call
    await Future.delayed(Duration(seconds: 1));
    if (email == 'user@example.com' && password == 'password') {
      return true;
    }
    return false;
  }

  Future<Map<String, String>> getTokens() async {
    return {
      'access_token': 'mock_access_token',
      'refresh_token': 'mock_refresh_token',
    };
  }
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final storage = await HydratedStorage.build(
    storageDirectory: await getApplicationDocumentsDirectory(),
  );
  HydratedBloc.storage = storage;

  final secureStorage = FlutterSecureStorage();
  final authRepository = AuthRepository();

  runApp(MyApp(
    authBloc: AuthBloc(secureStorage: secureStorage, authRepository: authRepository),
  ));
}

class MyApp extends StatelessWidget {
  final AuthBloc authBloc;

  const MyApp({required this.authBloc});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider.value(
        value: authBloc..add(AppStarted()),
        child: HomePage(),
      ),
    );
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final authBloc = BlocProvider.of<AuthBloc>(context);
    return BlocConsumer<AuthBloc, AuthState>(
      listener: (context, state) {
        if (state.status == AuthStatus.unauthenticated) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Logged out!')),
          );
        } else if (state.status == AuthStatus.authenticated) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Welcome ${state.userId}')),
          );
        }
      },
      builder: (context, state) {
        if (state.status == AuthStatus.unknown) {
          return Scaffold(body: Center(child: CircularProgressIndicator()));
        } else if (state.status == AuthStatus.authenticated) {
          return Scaffold(
            appBar: AppBar(title: Text('Dashboard')),
            body: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text('Logged in as user: ${state.userId}'),
                  SizedBox(height: 20),
                  ElevatedButton(
                    onPressed: () => authBloc.add(LoggedOut()),
                    child: Text('Logout'),
                  ),
                ],
              ),
            ),
          );
        } else {
          return LoginPage();
        }
      },
    );
  }
}

class LoginPage extends StatelessWidget {
  final TextEditingController emailController = TextEditingController();
  final TextEditingController passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    final authBloc = BlocProvider.of<AuthBloc>(context);
    return Scaffold(
      appBar: AppBar(title: Text('Login')),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: emailController,
              decoration: InputDecoration(labelText: 'Email'),
            ),
            TextField(
              controller: passwordController,
              obscureText: true,
              decoration: InputDecoration(labelText: 'Password'),
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () async {
                final success = await authBloc.authRepository.login(
                  emailController.text,
                  passwordController.text,
                );
                if (success) {
                  final tokens = await authBloc.authRepository.getTokens();
                  authBloc.add(LoggedIn(
                    userId: emailController.text,
                    accessToken: tokens['access_token']!,
                    refreshToken: tokens['refresh_token']!,
                  ));
                } else {
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(content: Text('Invalid credentials')),
                  );
                }
              },
              child: Text('Login'),
            ),
          ],
        ),
      ),
    );
  }
}

// Required Equatable import
import 'package:equatable/equatable.dart';

Test Your Knowledge

Q1
of 4

Which package is recommended for storing sensitive tokens securely in Flutter?

A
shared_preferences
B
flutter_secure_storage
C
hydrated_bloc
D
path_provider
Q2
of 4

What should you do on app start to restore a user session?

A
Show login screen immediately
B
Dispatch an AppStarted event and check persisted state
C
Clear all stored tokens
D
Wait for user interaction
Q3
of 4

How can you automatically refresh an expired token when an API call fails with 401?

A
Use a timer to refresh every 5 minutes
B
Implement an HTTP interceptor that handles 401 responses
C
Ask the user to log in again
D
Store the token in the database
Q4
of 4

What is the main benefit of using HydratedBloc for session management?

A
It automatically secures tokens
B
It persists the authentication state across app restarts
C
It reduces code size
D
It improves UI animations

Frequently Asked Questions

Should I store the entire user object in the auth state?

It depends. Storing minimal user data (like id, name, email) is fine, but for larger profiles, consider a separate bloc or repository to avoid bloating the auth state.

How do I handle logout from multiple devices?

Implement a push notification or web socket that triggers a LoggedOut event when the server invalidates the session. You can also periodically check token validity and force logout if the server returns an 'invalid token' error.

Can I use `hydrated_bloc` with `flutter_secure_storage` for tokens?

Yes, you can. The auth state stores tokens in memory, and you also write them to secure storage for API interceptors. This double storage is common.

What about biometric authentication?

You can extend your auth bloc to support biometrics by integrating local_auth. After biometric success, you can either store a special token or simply proceed with the existing session.

How do I test the auth bloc?

Use bloc_test package to test events and state transitions. Mock the repositories and secure storage to avoid side effects.

Previous

bloc offline support

Next

bloc streams

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.