This guide is self-contained — read it on its own and you'll have a working token-refresh system by the end. It's also Part 2 of a Flutter DIO networking series: if you want the surrounding
ApiConfigandApiResponselayers, Part 1: Foundation covers them; for the repository pattern that consumes the DIO client built here, see Part 3: Clean API Calls.
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. The fix uses flutter_secure_storage for token persistence, DIO interceptors for the auth flow, and a single shared Completer to coordinate concurrent refresh attempts.
What this post covers (jump to any section):
- Secure token storage — why SharedPreferences is wrong and how to use platform keystores
- The token refresh lock — one Completer that serializes refresh attempts
- The DIO interceptor that wires it all together — request and error handling end-to-end
- Resetting state on successful login — the bug that bites everyone once
- Testing the race condition fix — what you should see in your network inspector
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).
///
/// Note: Dart's event loop is single-threaded, so this check-then-set
/// is atomic. There is no `await` between the null check and the
/// assignment, so no other coroutine can interleave and acquire the
/// lock at the same time. In a multi-threaded language you'd need a
/// mutex here — in Dart, the event loop *is* the mutex.
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).
///
/// If a refresh is somehow still in flight when reset is called,
/// fail any awaiting requests with an explicit error rather than
/// silently nulling the completer — that would leak coroutines
/// stuck on `completer.future` forever.
static void reset() {
_refreshCompleter?.completeError(StateError('Refresh lock 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.
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. And if you have multiple providers that need to react when the logged-out flag flips, syncing state across Riverpod providers covers that exact handoff.
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);
}
// Critical: dio.fetch() re-runs the full interceptor chain.
// If the retried request also returns 401 (e.g., new token was
// immediately invalidated), we'd re-enter this interceptor,
// acquire the lock again, refresh again — forever.
// Mark retries via RequestOptions.extra to short-circuit on
// the second pass through.
if (error.requestOptions.extra['__retried'] == true) {
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.
// Mark as retried so a second 401 doesn't loop us back here.
error.requestOptions.headers['Authorization'] = 'Bearer $newToken';
error.requestOptions.extra['__retried'] = true;
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},
);
// Extract token (adjust based on your API's response format).
// Pulled out so the null-token failure path is explicit, not
// tucked inside a fall-through `throw`.
final newToken = refreshResponse.statusCode == 200
? (refreshResponse.data['access_token'] ??
refreshResponse.data['token'] ??
refreshResponse.headers
.value('authorization')
?.replaceFirst('Bearer ', ''))
: null;
if (newToken == null || (newToken is String && newToken.isEmpty)) {
throw Exception('Token refresh failed: no token in response');
}
// 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. Same retry guard as above.
error.requestOptions.headers['Authorization'] = 'Bearer $newToken';
error.requestOptions.extra['__retried'] = true;
final response = await dio.fetch(error.requestOptions);
return handler.resolve(response);
} 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;
});
Here is the step-by-step error interceptor flow for a 401 response:
- Request fails with 401 Unauthorized. Anything other than 401 falls through to the next handler — only auth failures trigger refresh logic.
- Is the failing request itself an auth endpoint (
/auth/login,/auth/refresh,/auth/register)? If yes, pass through without refreshing — a 401 on/auth/refreshmeans the refresh token itself is dead. - Has the user already been logged out from a previous failed refresh? If yes, reject immediately — no point trying again.
- Has this specific request already been retried once (
extra['__retried'] == true)? If yes, let it fail — this is the guard that preventsdio.fetch()from looping the interceptor forever. - Try to acquire the refresh lock. Only one request can hold the lock at a time.
- Lock not acquired: another request is already refreshing.
await completer.futurefor the new token, mark this request as retried, and retry with the new token viadio.fetch(). - Lock acquired: proceed to step 6.
- Lock not acquired: another request is already refreshing.
- Call
/auth/refreshwith the stored refresh token. - Extract the new access token from the response (try
access_token, thentoken, then theAuthorizationheader). - If extraction fails (null/empty token, or non-200 status): release the lock via
completeWithError(), clear all stored tokens viaclearAll(), and reject the original request with a "Session expired" error. - If extraction succeeds: save the new tokens, release the lock via
complete(newToken)so any waiting requests wake up with the new token, mark the original request as retried, and retry it viadio.fetch().
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
Frequently asked questions
Why does Flutter DIO call /auth/refresh multiple times when the token expires?
Because every request that gets a 401 independently triggers the error interceptor, and a naive implementation kicks off its own refresh. If a dashboard fires 4 parallel API calls and the token has just expired, you get 4 simultaneous /auth/refresh calls. The first one succeeds and invalidates the refresh token, the other 3 use the now-stale refresh token and fail — which usually logs the user out. The fix is a refresh lock (a Completer) that lets one request refresh while the others wait for the new token.
How do I prevent duplicate token refresh requests in Flutter?
Use a static Completer<String> as a refresh lock. The first 401 acquires the lock and starts the refresh; subsequent 401s see the lock is held and await the same Completer's future. When the refresh completes, all waiting requests receive the new token from the Completer and retry their original request. This is significantly more efficient than a boolean flag plus polling, and it propagates errors cleanly if the refresh itself fails.
Should I store auth tokens in SharedPreferences in Flutter?
No. SharedPreferences is plain XML on Android and an unencrypted plist on iOS — readable on rooted or jailbroken devices and recoverable from device backups. Use flutter_secure_storage instead: on Android it wraps the Keystore (set encryptedSharedPreferences: true), and on iOS it wraps Keychain (KeychainAccessibility.first_unlock is the standard choice for auth tokens). The API is almost identical to SharedPreferences but the tokens are encrypted at rest.
What happens if the refresh token itself expires?
The /auth/refresh call returns 401, the catch block fires, clearAll() wipes the stored tokens, and the lock is marked as logged-out so subsequent 401s short-circuit instead of trying to refresh again. The UI layer needs to listen for this and redirect to the login screen — typically by surfacing the error through a Riverpod provider and showing a session-expired snackbar. Without the logged-out flag, every queued request would keep attempting refreshes that can't possibly succeed.