SuriDevs Logo
Production-Ready Networking in Flutter with DIO - Part 2: Token Refresh

Production-Ready Networking in Flutter with DIO - Part 2: Token Refresh

By Sagar Maiyad  Jan 24, 2026

Let's run an experiment.

Open your app. Make sure you're logged in. Now, find where your dashboard loads data — probably something like this:

@override
void initState() {
  super.initState();
  _loadProfile();
  _loadNotifications();
  _loadRecentActivity();
  _loadUnreadMessages();
}

Four API calls. Normal stuff.

Now, expire your access token manually (or wait until it expires naturally) and refresh the page.

Open your network inspector. Count how many times /auth/refresh gets called.

If you see 4 refresh calls, you have the race condition problem. And if you're using a typical token refresh implementation from Stack Overflow, you probably do.

Here's what happens:

  1. All 4 requests fire simultaneously
  2. All 4 get 401 Unauthorized (token expired)
  3. All 4 try to refresh the token
  4. Server processes 4 refresh requests
  5. First one succeeds, returns new token
  6. Next 3 use the OLD refresh token (already invalidated)
  7. Those 3 fail, potentially logging out the user

I've seen this bug in production apps with millions of users. It's subtle — it doesn't happen every time. Just enough to generate confused user reports about "random logouts."

Let's fix it properly.

Step 1: Secure Token Storage

First, where are you storing tokens right now?

// If you're doing this, stop
SharedPreferences.setString('access_token', token);

SharedPreferences is not secure:

  • On Android: plain XML file in app directory
  • On iOS: plist file, unencrypted
  • On rooted/jailbroken devices: readable by other apps

For auth tokens, use platform secure storage:

// lib/core/services/token_manager.dart

import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class TokenManager {
  static const _storage = FlutterSecureStorage(
    aOptions: AndroidOptions(
      encryptedSharedPreferences: true,
    ),
    iOptions: IOSOptions(
      accessibility: KeychainAccessibility.first_unlock,
    ),
  );

  static const String _accessTokenKey = 'access_token';
  static const String _refreshTokenKey = 'refresh_token';
  static const String _userIdKey = 'user_id';

  Future<void> saveTokens({
    required String accessToken,
    String? refreshToken,
  }) async {
    await _storage.write(key: _accessTokenKey, value: accessToken);
    if (refreshToken != null) {
      await _storage.write(key: _refreshTokenKey, value: refreshToken);
    }
  }

  Future<String?> getAccessToken() async {
    return await _storage.read(key: _accessTokenKey);
  }

  Future<String?> getRefreshToken() async {
    return await _storage.read(key: _refreshTokenKey);
  }

  Future<void> saveUserId(String userId) async {
    await _storage.write(key: _userIdKey, value: userId);
  }

  Future<String?> getUserId() async {
    return await _storage.read(key: _userIdKey);
  }

  Future<bool> isAuthenticated() async {
    final token = await getAccessToken();
    final userId = await getUserId();
    return token != null && token.isNotEmpty && userId != null;
  }

  Future<void> clearAll() async {
    await _storage.deleteAll();
  }
}

// Riverpod provider
final tokenManagerProvider = Provider<TokenManager>((ref) => TokenManager());

"What does encryptedSharedPreferences: true do?"

On Android 6.0+, it uses the Android Keystore to encrypt the data. Even on rooted devices, the tokens aren't readable as plain text.

"What's KeychainAccessibility.first_unlock?"

On iOS, it means the data is accessible after the user unlocks the device once after boot. It's a balance between security and usability. Other options:

Option When Accessible Use Case
first_unlock After first unlock since boot Most apps
unlocked Only when device is unlocked High security
always Always, even when locked Background refresh (less secure)

For auth tokens, first_unlock is the standard choice.

Step 2: The Token Refresh Lock

This is the solution to the race condition. One class, 40 lines:

// lib/core/network/token_refresh_lock.dart

import 'dart:async';

/// Ensures only one token refresh happens at a time.
///
/// When 4 requests get 401 simultaneously:
/// - First request acquires the lock, starts refreshing
/// - Requests 2, 3, 4 see lock is taken, wait for the result
/// - First request completes, all 4 get the new token
/// - All 4 retry their original request with the new token
class TokenRefreshLock {
  static Completer<String>? _refreshCompleter;
  static bool _isLoggedOut = false;

  /// Is a refresh currently in progress?
  static bool get isRefreshing => _refreshCompleter != null;

  /// Has the user been logged out due to refresh failure?
  static bool get isLoggedOut => _isLoggedOut;

  /// Try to acquire the lock.
  /// Returns a Completer if you got the lock (you should refresh).
  /// Returns null if someone else is already refreshing (you should wait).
  static Completer<String>? tryAcquire() {
    if (_refreshCompleter == null && !_isLoggedOut) {
      _refreshCompleter = Completer<String>();
      return _refreshCompleter;
    }
    return null;
  }

  /// Get the current completer to wait on.
  static Completer<String>? get currentCompleter => _refreshCompleter;

  /// Call when refresh succeeds. Waiting requests will receive the new token.
  static void complete(String newToken) {
    _refreshCompleter?.complete(newToken);
    _refreshCompleter = null;
  }

  /// Call when refresh fails. Waiting requests will receive the error.
  static void completeWithError(Object error) {
    _refreshCompleter?.completeError(error);
    _refreshCompleter = null;
    _isLoggedOut = true;  // Prevent further refresh attempts
  }

  /// Reset state (call after successful login).
  static void reset() {
    _refreshCompleter = null;
    _isLoggedOut = false;
  }
}

"Why a Completer instead of a simple boolean lock?"

Because the waiting requests need to receive the new token once the refresh completes. A Completer lets them do this:

// Request 2, 3, 4 do this:
final newToken = await TokenRefreshLock.currentCompleter!.future;
// Now they have the new token without making another refresh call

If we used a boolean, they'd have to poll: "Is it done yet? Is it done yet?" That's wasteful and introduces timing bugs.

"What's the _isLoggedOut flag for?"

Once a refresh fails (refresh token expired or revoked), there's no point trying again. Every subsequent 401 should just fail immediately. The user needs to log in again — no amount of retrying will fix it.

Step 3: DIO Provider with Interceptors

Now we wire everything together. This is the largest file, but every line has a purpose:

// lib/core/network/dio_provider.dart

import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'api_config.dart';
import 'token_refresh_lock.dart';
import '../services/token_manager.dart';

final dioProvider = Provider<Dio>((ref) {
  final tokenManager = ref.read(tokenManagerProvider);

  final dio = Dio(
    BaseOptions(
      baseUrl: ApiConfig.fullBaseUrl,
      connectTimeout: ApiConfig.connectTimeout,
      receiveTimeout: ApiConfig.receiveTimeout,
      sendTimeout: ApiConfig.sendTimeout,
      headers: ApiConfig.defaultHeaders,
    ),
  );

  dio.interceptors.add(
    InterceptorsWrapper(
      // ────────────────────────────────────────────────────
      // REQUEST INTERCEPTOR
      // Runs before every request goes out
      // ────────────────────────────────────────────────────
      onRequest: (options, handler) async {
        final token = await tokenManager.getAccessToken();

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

        // Optional: identify platform for backend analytics
        if (Platform.isAndroid) {
          options.headers['X-Platform'] = 'android';
        } else if (Platform.isIOS) {
          options.headers['X-Platform'] = 'ios';
        }

        return handler.next(options);
      },

      // ────────────────────────────────────────────────────
      // ERROR INTERCEPTOR
      // Runs when a request fails
      // ────────────────────────────────────────────────────
      onError: (error, handler) async {
        // Only handle 401 Unauthorized
        if (error.response?.statusCode != 401) {
          return handler.next(error);
        }

        final requestPath = error.requestOptions.path;

        // Never try to refresh when the auth endpoints themselves fail.
        // If /auth/refresh returns 401, we're done — user needs to log in again.
        // If /auth/login returns 401, that's just wrong credentials.
        if (requestPath.contains('/auth/login') ||
            requestPath.contains('/auth/refresh') ||
            requestPath.contains('/auth/register')) {
          return handler.next(error);
        }

        // Already logged out from a previous refresh failure?
        // Don't even try.
        if (TokenRefreshLock.isLoggedOut) {
          return handler.reject(error);
        }

        // ── Try to acquire the refresh lock ──
        final acquiredLock = TokenRefreshLock.tryAcquire();

        if (acquiredLock == null) {
          // Someone else is already refreshing. Wait for them.
          final completer = TokenRefreshLock.currentCompleter;

          if (completer != null) {
            try {
              // Wait for the other request to finish refreshing
              final newToken = await completer.future;

              // Retry our original request with the new token
              error.requestOptions.headers['Authorization'] = 'Bearer $newToken';
              final response = await dio.fetch(error.requestOptions);
              return handler.resolve(response);
            } catch (e) {
              // The refresh failed. Our request fails too.
              return handler.reject(error);
            }
          }
          return handler.reject(error);
        }

        // ── We got the lock. Time to refresh. ──
        try {
          final refreshToken = await tokenManager.getRefreshToken();

          if (refreshToken == null) {
            throw Exception('No refresh token available');
          }

          // Call the refresh endpoint
          final refreshResponse = await dio.post(
            '/auth/refresh',
            data: {'refresh_token': refreshToken},
          );

          if (refreshResponse.statusCode == 200) {
            // Extract token (adjust based on your API's response format)
            final newToken = refreshResponse.data['access_token'] ??
                refreshResponse.data['token'] ??
                refreshResponse.headers
                    .value('authorization')
                    ?.replaceFirst('Bearer ', '');

            if (newToken != null && newToken.isNotEmpty) {
              // Save the new tokens
              await tokenManager.saveTokens(
                accessToken: newToken,
                refreshToken: refreshResponse.data['refresh_token'],
              );

              // Release the lock — waiting requests get the new token
              TokenRefreshLock.complete(newToken);

              // Retry our original request
              error.requestOptions.headers['Authorization'] = 'Bearer $newToken';
              final response = await dio.fetch(error.requestOptions);
              return handler.resolve(response);
            }
          }

          throw Exception('Token refresh failed');
        } catch (e) {
          // Refresh failed. Release the lock with error.
          TokenRefreshLock.completeWithError(e);

          // Clear stored tokens — user is logged out
          await tokenManager.clearAll();

          // Reject with a clear error
          return handler.reject(
            DioException(
              requestOptions: error.requestOptions,
              error: 'Session expired. Please log in again.',
              type: DioExceptionType.badResponse,
            ),
          );
        }
      },
    ),
  );

  // Debug logging for development only
  if (!ApiConfig.isProduction) {
    dio.interceptors.add(
      LogInterceptor(
        requestBody: true,
        responseBody: true,
        logPrint: (obj) => print('🌐 $obj'),
      ),
    );
  }

  return dio;
});

Let's walk through the error interceptor flow:

Request fails with 401
        │
        ├── Is it an auth endpoint? ──Yes──► Pass through (don't refresh)
        │
        No
        │
        ├── Already logged out? ──Yes──► Reject immediately
        │
        No
        │
        ├── Try to acquire lock
        │
        ├── Lock acquired? ──No──► Wait for completer.future
        │                                    │
        Yes                                  └──► Retry with new token
        │
        ├── Call /auth/refresh
        │
        ├── Success? ──No──► completeWithError(), clearAll(), reject
        │
        Yes
        │
        ├── Save new tokens
        ├── complete(newToken)  ← Waiting requests wake up here
        └── Retry original request

Don't Forget: Reset on Login

When a user logs in successfully, reset the lock:

// In your AuthService or LoginViewModel
Future<bool> login(String email, String password) async {
  final response = await dio.post('/auth/login', data: {
    'email': email,
    'password': password,
  });

  if (response.statusCode == 200) {
    await tokenManager.saveTokens(
      accessToken: response.data['access_token'],
      refreshToken: response.data['refresh_token'],
    );

    // Important: clear the logged-out state
    TokenRefreshLock.reset();

    return true;
  }
  return false;
}

If you forget this, users who were logged out due to refresh failure won't be able to make authenticated requests even after logging in again.

Testing the Race Condition Fix

Here's how to verify it works:

void testTokenRefreshLock() async {
  // Simulate 4 requests getting 401 at the same time
  final results = await Future.wait([
    simulateExpiredTokenRequest('/profile'),
    simulateExpiredTokenRequest('/notifications'),
    simulateExpiredTokenRequest('/settings'),
    simulateExpiredTokenRequest('/messages'),
  ]);

  // Check network inspector:
  // - Should see only 1 call to /auth/refresh
  // - Should see all 4 original requests retried
}

In your network inspector, you should see:

GET /profile              → 401
GET /notifications        → 401
GET /settings             → 401
GET /messages             → 401
POST /auth/refresh        → 200  ← Only one!
GET /profile              → 200  (retried)
GET /notifications        → 200  (retried)
GET /settings             → 200  (retried)
GET /messages             → 200  (retried)

If you see 4 refresh calls, something's wrong with the lock implementation.

What We Have Now

lib/
└── core/
    ├── network/
    │   ├── api_config.dart
    │   ├── api_response.dart
    │   ├── api_error_handler.dart
    │   ├── token_refresh_lock.dart  ← NEW
    │   └── dio_provider.dart        ← NEW
    └── services/
        └── token_manager.dart       ← NEW
File Responsibility
token_manager.dart Secure token storage
token_refresh_lock.dart Coordinate concurrent refresh attempts
dio_provider.dart Configure DIO with auth interceptors

What's Next

In Part 3, we build the final layer: BaseApiService and repository pattern. This is where we get those clean API calls:

final response = await userRepository.getProfile();
if (response.success) {
  // done
}

We'll also put together the complete flow diagram and a checklist for production deployment.

Checklist Before Moving On

  • [ ] Tokens stored in FlutterSecureStorage, not SharedPreferences
  • [ ] TokenRefreshLock.reset() called after successful login
  • [ ] Auth endpoints (/login, /refresh, /register) excluded from refresh logic
  • [ ] Debug logging only in non-production builds
  • [ ] Tested with multiple simultaneous 401s — only 1 refresh call

See you in Part 3.


This is Part 2 of a 3-part series:

  • Part 1: Foundation — ApiConfig, ApiResponse, ErrorHandler
  • Part 2: Token Refresh (you're here) — TokenManager, TokenRefreshLock, DIO Interceptors
  • Part 3: Clean API Calls — BaseApiService, Repository pattern
Flutter DIO Authentication Token Refresh Dart

Author

Sagar Maiyad
Written By
Sagar Maiyad

Sagar Maiyad - Android developer specializing in Kotlin, Jetpack Compose, and modern Android architecture. Sharing practical tutorials and real-world development insights.

View All Posts →

Latest Post

Latest Tags