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:
- All 4 requests fire simultaneously
- All 4 get 401 Unauthorized (token expired)
- All 4 try to refresh the token
- Server processes 4 refresh requests
- First one succeeds, returns new token
- Next 3 use the OLD refresh token (already invalidated)
- 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, notSharedPreferences - [ ]
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