Firebase auth should cover about 90% of a Flutter app's authentication needs, but what if you need to roll your own auth? Then, you'd need to manually handle trading and saving your JWT tokens from the backend to your app's secure storage.
Now, your JWT should have an expiration date, that means you need to refresh your tokens periodically using the refresh token sent by your backend. All seems good until your user logs in to your app after 60 days, by then your access and refresh tokens should expire, and now you are getting 401 errors in your app.
Your Dio refresh token interceptor should try to get new tokens using the saved refresh token, but if your refresh token is expired, that won't work. If the user is conscious enough, they would try to logout and login again to get new tokens, but wouldn't it be nicer if in cases of refreshing token failures, our app automatically logs the user out?
This might be a rare occurrence, but it was a requirement I had to deal with for a recent project I was working on, Kawal. In this article, I'll explain how I handled refreshing JWT token failures by reactively logging out the user using RxDart. I'll also provide a Flutter app project to explain how the core system works.
Kawal Case Study
The app handles custom JWT authentication using a straightforward flow:
- User logs in
- Backend responds with an access and refresh token
- Store both of these using flutter secure storage
- On app startup, read from the secure storage
- If both tokens exist, then the user is authenticated
Since I’m binding the existence of those tokens to the users authenticated status, logging out could simply be clearing that token and redirecting the user to the login page. Before we move on, this is how the app's auth system is architected:

The RefreshTokenInterceptor handles refreshing access token by checking if any request responds with a 401 status from the backend. If it does, the interceptor will hit a refresh token endpoint to get and store the new tokens. But, there are situations where refreshing token could fail, for example when the server is down or the refresh token expires. My team agreed that if this happens, we would log the user out.
Now I could have this setup to handle that logout:

Since the existence of auth token is what sets the authentication status, clearing it should suffice. But, our app also have other logic to do during logout:
- Clearing shared preferences
- Calling Google Analytics
These methods are handled by the
AuthRepository.logoutmethod, so the architecture really looks like this:
Since the AuthRepository.logout method is the source-of-truth for logging out, now logging out just by clearing the secure storage in the refresh token interceptor is not sufficient. So why not just use AuthRepository inside of the RefreshTokenInterceptor? Well…

Now there’s a circular dependency: Dio is used by AuthService, which is used by AuthRepository, which is used by RefreshTokenInterceptor, which is used by Dio, which is used by AuthService… not good.
One option is to just copy-paste the logout method in AuthRepository to RefreshTokenInterceptor, but I prefer to keep things DRY. So in this article, I’ll show you how you can handle this problem by making use of good ol’ RxDart as a signal for app to logout at any point we want.
Tutorial: Getting user’s profile data using JWT
I've built an example project using Platzi Fake Store API | Platzi Fake Store API since it supports JWT auth. You can clone the repository here to read along the code and I'll explain the core mechanics for the rest of this article.
The app has simple features:
- Unauthenticated users must login/register
- Authenticated users are redirected to the home screen where they can see their auth-protected user profile
The Platzi API requires us to provide the JWT to get the user’s profile, so this part will be a good example of how we can handle reactive authentication.
RxDart Basic Concepts
Before we dive in to the tutorial, we need to know a few basic concept of RxDart, mainly BehaviorSubject and PublishSubject. These classes are what we call Observables in reactive programming, basically they are “things” our app can “listen” to and based on what “thing” that happened, our app can run certain logic (listeners).
These “things” we listen to are Streams. If you have ever used something like Kafka or Pub/Sub, its basically like how you can publish topics and your subscribers/consumers can react to it by triggering certain callbacks, for example calling a function, API endpoint, etc. With reactive programming, we can send (emit) values over time to the stream and our listeners can receive those values to do whatever they want.
The main difference between BehaviorSubject and PublishSubject is BehaviorSubjects stores the last emitted value to the stream (stateful) while PublishSubjects don’t (ephemeral). So if a new listener comes in, they will instantly receive the last value; unlike PublishSubject that doesn’t store the last emitted value, so new listeners won’t know what happened last time.

We can use these concepts to solve our reactive logout problem. Imagine if we only cleared the auth token from secure storage, then what? Flutter doesn’t know the user should be redirected to the login screen, we have to do it ourselves. But, redirection is a view layer concern, while our AuthRepository.logout method lives in the AuthRepository which should only concern business logic problems. That means, signaling our app to logout consists of 2 steps:
- Clearing the auth token from secure storage (data layer)
- Redirecting the user to login screen (view layer)
We’ll handle these steps by leveraging reactive programming, we’ll need to setup 2 streams: a stream built with BehaviorSubject to store the user's authentication status and a stream built with PublishSubject to signal our app to logout.
Architecture
Each layer handles different concerns:
- Services: directly calls the Platzi API and returns DTOs (Data Transfer Objects)
- Repositories: utilizes multiple services and streams to build the business logic
- Cubits: our ViewModel to abstract away our views’ logic by orchestrating multiple repositories and store state
- Views: screens that will simply present the data from our cubits states
We’ll also spice things up by introducing dependency injection using get_it. This might seem overkill for this simple app, but I find this setup as a good basis when managing my app that has a lot of business logic and screens going around.
Create AppSignal to emit logout signal
We need a signal that is accessible to any layer of our app without causing a circular dependency. We can fix this easily by creating a dependency called AppSignal that stores any global signals our app can emit/listen to.
// lib/data/core/app_signals.dart
import 'package:logging/logging.dart';
import 'package:rxdart/rxdart.dart';
class AppSignals {
final _log = Logger('AppSignals');
final _logoutSignalSubject = PublishSubject<Null>();
Stream<Null> get logoutSignalStream => _logoutSignalSubject.stream;
Future<void> emitLogoutSignal() async {
_log.info('Emitting logout signal');
_logoutSignalSubject.add(null);
_log.info('Logout signal emitted');
}
}
We expose a logoutSignalStream that will be listened to by our app to trigger AuthRepository.logout method. With the emitLogoutSignal, we can signal our app to logout anytime and anywhere, since this dependency is a core dependency that can be used on any layer of our app that has access to get_it/any other DI framework you implement.

Listen to logoutSignalStream in AuthRepository
I placed my logout method in AuthRepository, alongside a BehaviorSubject to store the user's current auth status. The logout method will be responsible to clear the auth token, update the user's auth status stream, and do other things such as sending analytics.
// lib/data/repositories\auth_repository.dart
import 'package:flutter_reactive_logout_example/data/core/app_error.dart';
import 'package:flutter_reactive_logout_example/data/core/app_signals.dart';
import 'package:flutter_reactive_logout_example/data/services/auth_service.dart';
import 'package:flutter_reactive_logout_example/data/storage/secure_storage.dart';
import 'package:flutter_reactive_logout_example/domain/enums/auth_status.dart';
import 'package:flutter_reactive_logout_example/domain/models/auth_token.dart';
import 'package:flutter_reactive_logout_example/domain/models/login_request.dart';
import 'package:flutter_reactive_logout_example/domain/models/register_request.dart';
import 'package:flutter_reactive_logout_example/domain/models/user.dart';
import 'package:logging/logging.dart';
import 'package:rxdart/rxdart.dart';
abstract class AuthRepository {
Stream<AuthStatus> get authStatusStream;
AuthStatus get currentAuthStatus;
Future<void> register(RegisterRequest registerRequest);
Future<void> login(LoginRequest loginRequest);
Future<User> getUserProfile();
Future<void> logout();
void dispose();
}
class AuthRepositoryRemote extends AuthRepository {
final Logger _log = Logger('AuthRepositoryRemote');
final AuthService _authService;
final SecureStorage _secureStorage;
final AppSignals _appSignals;
final BehaviorSubject<AuthStatus> _authStatusSubject =
BehaviorSubject<AuthStatus>.seeded(AuthStatus.unknown);
@override
Stream<AuthStatus> get authStatusStream => _authStatusSubject.stream;
@override
AuthStatus get currentAuthStatus => _authStatusSubject.value;
AuthRepositoryRemote({
required AuthService authService,
required SecureStorage secureStorage,
required AppSignals appSignals,
}) : _authService = authService,
_secureStorage = secureStorage,
_appSignals = appSignals {
// ...
_listenToLogoutSignal();
}
void _listenToLogoutSignal() {
_appSignals.logoutSignalStream.listen((_) async {
_log.info('Logout signal received, performing logout');
await logout();
});
}
// ...
@override
Future<void> logout() async {
try {
_log.info('Logging out user');
await _secureStorage.clearAuthToken();
// TODO do other things (send analytics, clear shared preferences, etc.)
_authStatusSubject.add(AuthStatus.unauthenticated);
_log.info('Logout successful');
} catch (e, stack) {
final appError = _mapToAppError(e);
_log.severe('Logout failed', appError, stack);
_authStatusSubject.add(AuthStatus.unauthenticated);
throw appError;
}
}
// ...
}
During the construction of the class, we depend on the AppSignals and listen to its logoutSignalStream to trigger the logout method. Later, the rest of our app can emit a logout signal and our AuthRepository will handle logging out. This is much cleaner, since if we ever need to modify the logout method, we just have to modify it in this one repository.
Emit logout signal in refresh token interceptor
We'll handle refreshing the user's token using a Dio interceptor.
// lib/config/dio/interceptors/refresh_token_interceptor.dart
import 'package:dio/dio.dart';
import 'package:flutter_reactive_logout_example/config/api_routes.dart';
import 'package:flutter_reactive_logout_example/data/core/app_error.dart';
import 'package:flutter_reactive_logout_example/data/core/app_signals.dart';
import 'package:flutter_reactive_logout_example/data/storage/secure_storage.dart';
import 'package:flutter_reactive_logout_example/domain/models/auth_token.dart';
import 'package:logging/logging.dart';
/// Uses [QueuedInterceptorsWrapper] so if multiple request fails at once, we refresh
/// the first one before retrying the rest using the new token
class RefreshTokenInterceptor extends QueuedInterceptorsWrapper {
final Dio _dio;
final SecureStorage _storage;
final AppSignals _appSignals;
final _log = Logger('RefreshTokenInterceptor');
RefreshTokenInterceptor({
required Dio dio,
required SecureStorage storage,
required AppSignals appSignals,
}) : _dio = dio,
_storage = storage,
_appSignals = appSignals;
// ...
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final isUnauthorized = err.response?.statusCode == 401;
// Avoid infinite loop if the refresh-token endpoint itself fails
final isRefreshRequest = err.requestOptions.path.contains(
ApiRoutes.refreshToken,
);
if (isUnauthorized && !isRefreshRequest) {
_log.warning('Token expired! Refreshing token...');
// ... handle refreshing logic
} catch (e) {
_log.severe('Refresh token failed. Logging user out', e);
final appError = AppError.from(e);
// Important piece to signal logout for our app
_appSignals.emitLogoutSignal();
return handler.next(
DioException(requestOptions: err.requestOptions, error: appError),
);
}
}
return handler.next(err);
}
}
Notice how it wraps the refreshing logic in a try/catch, and the catch block calls _appSignals.emitLogoutSignal(). Whenever the refresh token logic fails, we can assume the user should logout to get new tokens. By calling that method, we'll trigger the listener in AppRepository earlier that calls the logout method. Side note, this interceptor only runs if your request encounters an error, specifically a 401 error. My team agreed that encountering a 401 status means the user should try to refresh their tokens, but your requirement may vary.
Reactively navigate using GoRouter's refreshListenable
So far we've only managed the auth status in the data layer, but haven't really tied it with the app's actual navigation to the login screen. We can use GoRouter's refreshListenable to listen to a Listenable and trigger navigation whenever that Listenable changes.
// lib/routing/router.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_reactive_logout_example/domain/enums/auth_status.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_reactive_logout_example/config/dependencies.dart';
import 'package:flutter_reactive_logout_example/data/repositories/auth_repository.dart';
import 'package:flutter_reactive_logout_example/ui/home/cubit/home_cubit.dart';
import 'package:flutter_reactive_logout_example/ui/home/widgets/home_screen.dart';
import 'package:flutter_reactive_logout_example/ui/login/cubit/login_cubit.dart';
import 'package:flutter_reactive_logout_example/ui/login/widgets/login_screen.dart';
import 'package:flutter_reactive_logout_example/ui/register/cubit/register_cubit.dart';
import 'package:flutter_reactive_logout_example/ui/register/widgets/register_screen.dart';
import 'package:flutter_reactive_logout_example/ui/splash/splash_screen.dart';
import 'routes.dart';
/// AppRouter class that configures go_router with authentication guard and reactive navigation
class AppRouter extends ChangeNotifier {
AppRouter() {
_setupAuthListener();
}
final AuthRepository _authRepository =
DependencyManager.get<AuthRepository>();
/// Listen to any stream from any repositories to reactively update the user's navigation
void _setupAuthListener() {
_authRepository.authStatusStream.listen((_) {
notifyListeners();
});
}
/// Creates the GoRouter instance with auth guard and route configuration
GoRouter get router {
return GoRouter(
initialLocation: Routes.splashScreen,
redirect: _handleRedirect,
routes: _routes,
refreshListenable: this,
);
}
/// Auth guard redirect logic
/// Listens to authStatusStream and redirects based on authentication state
Future<String?> _handleRedirect(
BuildContext context,
GoRouterState state,
) async {
final authRepository = DependencyManager.get<AuthRepository>();
final authStatus = authRepository.currentAuthStatus;
final location = state.uri.toString();
// Define public routes that don't require authentication
final isPublicRoute =
location == Routes.login || location == Routes.register;
// Handle redirect based on auth status
switch (authStatus) {
case AuthStatus.authenticated:
if (location == Routes.splashScreen || isPublicRoute) {
return Routes.home;
}
break;
case AuthStatus.unauthenticated:
if (!isPublicRoute) {
return Routes.login;
}
break;
case AuthStatus.unknown:
break;
}
return null;
}
/// Route definitions
List<GoRoute> get _routes => [
GoRoute(
path: Routes.splashScreen,
name: 'splash',
builder: (context, state) => const SplashScreen(),
),
GoRoute(
path: Routes.login,
name: 'login',
builder: (context, state) => BlocProvider(
create: (context) =>
LoginCubit(authRepository: DependencyManager.get<AuthRepository>()),
child: const LoginScreen(),
),
),
GoRoute(
path: Routes.register,
name: 'register',
builder: (context, state) => BlocProvider(
create: (context) => RegisterCubit(
authRepository: DependencyManager.get<AuthRepository>(),
),
child: const RegisterScreen(),
),
),
GoRoute(
path: Routes.home,
name: 'home',
builder: (context, state) => BlocProvider(
create: (context) =>
HomeCubit(authRepository: DependencyManager.get<AuthRepository>()),
child: const HomeScreen(),
),
),
];
}
The way I did it is by wrapping the router in a class that extends ChangeNotifier. This effectively turns this into a Listenable that the router listens to. This allows us to listen to multiple streams and notify the Listenable (such as the _setupAuthListener method), that will then, trigger the redirect method of the router. The _handleRedirect method will be triggered whenever we notify something through a stream listener or any other method inside of the class.
We can then wrap the router around the main app:
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_reactive_logout_example/config/dependencies.dart';
import 'package:flutter_reactive_logout_example/routing/router.dart';
import 'package:logging/logging.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
debugPrint('${record.level.name}: ${record.time}: ${record.message}');
});
await DependencyManager.setup();
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
final appRouter = AppRouter();
return MaterialApp.router(
routerConfig: appRouter.router,
title: 'Flutter Reactive Logout Example',
debugShowCheckedModeBanner: false,
);
}
}
Now, we have finished tying together our logout signal to our app's navigation:
- Refresh token fails, emit logout signal in
RefreshTokenInterceptor -
AuthRepositorylisten to logout signal to calllogout, which will update ourauthStatusStreaminto unauthenticated -
AppRouter.routerlistens to the auth status change, and redirect user to the login page since they are now unauthenticated
That's it!
By leveraging RxDart, we can store the user's auth status and signal our app to logout by exposing streams. Our app signals can be listened to by any layer of our app since its a core dependency. We utilize this by emitting logout signal when token refresh fails, which will reactively update our auth status and redirec the user to the login screen.
You should run the demo app to see how the interceptor and stream works. There is a demo logout button in home_screen.dart that will always trigger the RefreshTokenInterceptor catch block.
Have you ever encountered a similar situation where you need to logout the user when the token refresh fails? I'd love to hear your experiences in the comments!


Top comments (0)