Sitemap

How I Handle Automatic Logout When a Refresh Token Expires in Flutter

8 min read6 days ago

--

Press enter or click to view image in full size

Authentication is one of the most important parts of any Flutter application. Whether we are building a shopping app, social media platform, banking application or any other application that requires users to log in, managing user sessions properly is essential.

In most applications we don’t want users to enter their email and password every time they open the app. That’s why we use access tokens and refresh tokens to keep users logged in.

But there is one situation we need to handle carefully: What happens when the refresh token expires?

If the refresh token expires and we don’t handle it properly, users might continue using an application with an invalid session, API requests may fail repeatedly, or the application may get stuck in an authentication loop.

In this article i will explain you that how i handle automatic logout in Flutter when a refresh token expires, why it is important and how we can implement this functionality in a real-world Flutter application.

Understanding Access Tokens and Refresh Tokens

Before implementing automatic logout we must have to understand why we use two different tokens.

When a user successfully logs in, the backend generally provides two tokens:

(i). Access Token: A short-lived token used to access protected APIs.

(ii). Refresh Token: A longer-lived token used to generate a new access token when the current one expires.

For example imagine a user logs into our Flutter application.

The backend returns:

{
"access_token": "access_token_123",
"refresh_token": "refresh_token_456"
}

Let’s say the access token expires after 15 minutes while the refresh token expires after 7 days.

When the access token expires we don’t immediately log the user out. Instead we use the refresh token to request a new access token.

This allows users to continue using the application without logging in repeatedly.

However once the refresh token also expires, we can no longer generate a new access token. At that point, the user’s session is no longer valid, and we need to log them out.

What should happen when a Refresh Token expires?

Let’s understand this with a practical example.

Imagine a user opens our Flutter application and logs in successfully. Everything works fine for several days.

After some time both tokens expire.

Now when the user tries to access their profile, the application sends an API request. The backend responds that the refresh token is invalid or expired.

At this point our application should:

(1). Detect that the refresh token has expired.

(2). Clear the saved access and refresh tokens.

(3). Remove user session data if required.

(4). Navigate the user to the login screen.

(5). Prevent further authenticated API requests until the user logs in again.

The important thing is that we should handle this process automatically instead of expecting users to manually log out.

How i implement automatic Logout in Flutter

For this implementation i will use Dio for API requests and GetStorage for storing authentication tokens.

The same concept can also be implemented using other state management solutions.

Let’s divide the implementation into a few simple parts.

(1). Store Authentication Tokens after Login

First after successful login we need to save both tokens locally.

I am using GetStorage because it provides a simple way to store small amounts of local data.

Add these dependencies to our pubspec.yaml file.

dependencies:
dio: ^5.11.1
get_storage: ^2.1.1
get: ^4.7.3

Initialize GetStorage inside main.dart :

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

await GetStorage.init();

runApp(MyApp());
}

Now let’s create a simple function to save our tokens.

import 'package:get_storage/get_storage.dart';

class TokenStorage {
final storage = GetStorage();

Future<void> saveTokens(
String accessToken,
String refreshToken,
) async {
await storage.write('access_token', accessToken);
await storage.write('refresh_token', refreshToken);
}

String? getAccessToken() {
return storage.read('access_token');
}

String? getRefreshToken() {
return storage.read('refresh_token');
}
}

Whenever the login API returns successful authentication tokens we call saveTokens().

This allows us to access the stored tokens whenever we make authenticated API requests.

Note: For production applications consider secure storage such as Flutter Secure Storage for sensitive authentication tokens instead of relying on ordinary local storage.

(2). Create a Logout Function

Before handling refresh token expiration we need a centralized logout function.

I prefer keeping logout logic in one place because we may need to trigger it from different parts of our application.

For example:

(i). When the refresh token expires.

(ii). When the user manually logs out.

(iii). When the backend invalidates the session.

(iv). When the user changes their password and existing sessions are revoked.

Let’s create an authentication controller.

class AuthController extends GetxController {

final storage = GetStorage();

void logout() {
storage.remove('access_token');
storage.remove('refresh_token');

Get.offAllNamed('/login');
}
}

Here Get.offAllNamed() removes the previous navigation stack and takes the user directly to the login screen.

Get Ankit Mehra’s stories in your inbox

Join Medium for free to get updates from this writer.

This is important because users should not be able to press the back button and return to authenticated screens after logout.

(3). Create a Dio Interceptor

This is where most of the authentication logic happens.

Instead of checking token expiration manually in every API function we can use a Dio interceptor.

An interceptor allows us to execute common logic before sending a request or after receiving a response.

For example whenever an API request returns an unauthorized response (HTTP 401), we can check whether the access token has expired and attempt to refresh it.

Let’s create an API client:

class ApiClient {

final Dio dio = Dio();

ApiClient() {
dio.interceptors.add(
InterceptorsWrapper(

onRequest: (options, handler) {
final token = GetStorage().read('access_token');

if (token != null) {
options.headers['Authorization'] =
'Bearer $token';
}

handler.next(options);
},

onError: (error, handler) async {

if (error.response?.statusCode == 401) {

// Handle token refresh here

}

handler.next(error);
},
),
);
}
}

Now every API request automatically receives the saved access token.

We also have a central place to handle unauthorized responses.

(4). Refresh the Access Token automatically

Now let’s implement the most important part: refreshing the access token.

When the backend returns a 401 response we can send another request using the refresh token.

If the refresh token is still valid the backend will return a new access token.

For example the refresh API might return:

{
"access_token": "new_access_token_789",
"refresh_token": "new_refresh_token_123"
}

Some backends rotate refresh tokens while others keep the same refresh token. We should save the new refresh token whenever the backend provides one.

Let’s create a refresh function:

Future<bool> refreshToken() async {

final storage = GetStorage();

final refreshToken = storage.read('refresh_token');

if (refreshToken == null) {
return false;
}

try {

final response = await Dio().post(
'https://api.example.com/auth/refresh',
data: {
'refresh_token': refreshToken,
},
);

if (response.statusCode == 200) {

final newAccessToken = response.data['access_token'];

final newRefreshToken = response.data['refresh_token'];

await storage.write('access_token',newAccessToken);

if (newRefreshToken != null) {
await storage.write(
'refresh_token',
newRefreshToken,
);
}

return true;
}

return false;

} catch (e) {
return false;
}
}

Here is what happens:

  • We first retrieve the saved refresh token.
  • If no refresh token exists we return false.
  • We send the refresh token to the backend.
  • If the request succeeds, we save the new access token.
  • If the backend also returns a new refresh token, we save that too.
  • If the refresh request fails, we return false.

Returning false tells our application that it couldn't refresh the session.

One important point: A network error doesn’t necessarily mean that the refresh token has expired. In a production application, we should distinguish between authentication failures and temporary network problems instead of logging users out whenever the refresh request cannot reach the server.

(5). Automatically Logout when Refresh fails

Now we can connect our refresh function with the logout functionality.

If the access token expires our application attempts to refresh it.

If the refresh request returns an authentication failure it means the backend no longer accepts our refresh token.

In that situation we should clear the saved session and redirect the user to the login screen.

For example:

class AuthController extends GetxController {

final storage = GetStorage();

Future<void> logout() async {
await storage.remove('access_token');
await storage.remove('refresh_token');

Get.offAllNamed('/login');
}
}

We can call this function whenever the backend confirms that the refresh token is expired, invalid, revoked or otherwise unusable.

This keeps our authentication logic centralized and makes it easier to maintain.

(6). Handle everything inside the Dio Interceptor

We have created the token storage, refresh function, and logout function.

Now let’s bring everything together.

The following is a simplified production-oriented example of how the interceptor can handle token expiration and retry the original request.

class ApiClient {

final Dio dio = Dio();

final storage = GetStorage();

bool isRefreshing = false;

ApiClient() {

dio.interceptors.add(
InterceptorsWrapper(

onRequest: (options, handler) {

final token =
storage.read('access_token');

if (token != null) {
options.headers['Authorization'] =
'Bearer $token';
}

handler.next(options);
},

onError: (error, handler) async {

if (error.response?.statusCode == 401 &&
error.requestOptions.extra['retried'] != true) {

if (isRefreshing) {
handler.next(error);
return;
}

isRefreshing = true;

try {

final success = await refreshToken();

if (success) {

final options = error.requestOptions;

options.extra['retried'] = true;

final newToken =
storage.read('access_token');

options.headers['Authorization'] =
'Bearer $newToken';

final response = await dio.fetch(options);

handler.resolve(response);

} else {

await AuthController().logout();

handler.next(error);
}

} catch (e) {

handler.next(error);

} finally {

isRefreshing = false;
}

} else {

handler.next(error);
}
},
),
);
}

Future<bool> refreshToken() async {

final refreshToken =
storage.read('refresh_token');

if (refreshToken == null) {
return false;
}

try {

final response = await Dio().post(
'https://api.example.com/auth/refresh',
data: {
'refresh_token': refreshToken,
},
);

if (response.statusCode == 200) {

await storage.write(
'access_token',
response.data['access_token'],
);

if (response.data['refresh_token'] != null) {

await storage.write(
'refresh_token',
response.data['refresh_token'],
);
}

return true;
}

return false;

} on DioException catch (e) {

if (e.response?.statusCode == 401 ||
e.response?.statusCode == 403) {
return false;
}

rethrow;
}
}
}

Important things i keep in mind

While implementing automatic logout there are a few things i always consider.

(i). Don’t log out users when only the access token expires.

An expired access token doesn’t necessarily mean the user’s session has ended. First try refreshing it.

(ii). Don’t retry the same request continuously.

If the original request returns 401 and the retry also returns 401 we should stop retrying. Otherwise, our application can enter an endless request loop.

(iii). Don’t refresh tokens for every failed API request.

A 404 means that a resource wasn’t found, while a 500 generally indicates a server-side problem. Neither automatically means that the refresh token has expired.

(iv). Handle multiple API requests carefully.

If five API requests fail simultaneously we should ideally make only one refresh request and let the other requests wait for its result.

(v). Keep authentication logic in one place.

Using a centralized Dio interceptor makes token handling easier to maintain instead of repeating authentication checks in every API function.

(vi). Store sensitive tokens securely.

Access and refresh tokens should be protected using secure storage, and communication with the backend should use HTTPS.

(vii). Let the backend decide whether a refresh token is valid.

We shouldn’t depend only on the token’s locally decoded expiration time. The backend may revoke a token before its expiry, rotate it or invalidate the session for security reasons.

Final Thoughts

Automatic logout helps keep our Flutter application secure and sessions properly managed. By combining access tokens, refresh tokens, and Dio interceptors, we can refresh expired sessions automatically and log users out when their refresh token becomes invalid.

The goal is simple: keep users logged in when possible and log them out when necessary.

--

--

Ankit Mehra
Ankit Mehra

Written by Ankit Mehra

Flutter developer sharing practical tips from real projects.