DEV Community

Cover image for Flutter Dio Token Refresh: Fixing the Race Condition Most Tutorials Miss
SuriDevs
SuriDevs

Posted on • Originally published at suridevs.com

Flutter Dio Token Refresh: Fixing the Race Condition Most Tutorials Miss

Quick experiment you can run in five minutes.

Open your Flutter app. Find a screen that fires multiple API calls on load — dashboard, profile, anything that does this:

@override
void initState() {
  super.initState();
  _loadProfile();
  _loadNotifications();
  _loadRecentActivity();
  _loadUnreadMessages();
}
Enter fullscreen mode Exit fullscreen mode

Four API calls. Normal stuff.

Now expire your access token (manually, or wait it out) and reload. Open your network inspector and count how many times /auth/refresh gets called.

If you see 4, you have the race condition I shipped to production once. Twice, actually. Took three failed mitigations before I landed on the lock pattern below.

Here's what happens when 4 requests hit 401 at the same time:

  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);
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

"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;
  }
}
Enter fullscreen mode Exit fullscreen mode

"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
Enter fullscreen mode Exit fullscreen mode

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.

When that happens, the UI side needs to surface it cleanly — usually a snackbar saying "Your session expired" plus a redirect to the login screen, not a full-page error that confuses the user. Showing the right error state for auth failures is its own pattern on top of this network layer.

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;
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode

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

Originally published at suridevs.com.

Top comments (0)