Authentication is a foundational requirement for most apps. In Flutter, a common pattern uses JWTs (JSON Web Tokens) or similar tokens to authenticate requests. At first, you might rely on a single access token—but as your app scales, you’ll quickly need a refresh token strategy to avoid forcing users to log in over and over.
In this article, we’ll move through three stages of an authentication flow:
- A simple app with only an access token: Suitable for short-lived demos or backends that never expire tokens.
- A refresh token flow with “One Future”: Ensures only one refresh request is triggered, but can get clunky with concurrency.
- Drawbacks of “One Future” and how to optimize using a flag + queue approach for high concurrency.
Let’s dive in!
1) The Simplest Setup: Only an Access Token
Imagine you have an API that doesn’t expire tokens—or your app’s token can be reissued manually. Your Dio interceptor might look like this:
class SimpleAuthInterceptor extends Interceptor {
final Dio dio;
final SecureStorage secureStorage; // or any storage
SimpleAuthInterceptor(this.dio, this.secureStorage);
@override
Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final token = await secureStorage.readToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
}
Here:
- We read an access token from storage.
- Attach it to every request in the
Authorization: Bearer ...
header. - If the token is invalid or expires, we rely on the backend to tell us (maybe a
401
), at which point we might just fail gracefully or prompt the user to re-log in.
Pros:
- Very simple—no refresh logic, no concurrency issues.
Cons:
- The user must log in again every time the token expires. That’s extremely annoying if tokens expire frequently.
- If the server can revoke your token unpredictably, you have no fallback other than a
401
.
This works for minimal demos or local dev, but in real production apps, having only an access token is rarely enough.
2) Introducing a Refresh Token Using the “One Future” Approach
When your access token can expire, a refresh token is typically provided by the backend. This refresh token can be used to request a new access token without prompting the user for credentials again. In Flutter/Dio, we can code it like so:
- In
onRequest
, attach the current access token. - If a request fails with
401
, check if we’re already refreshing. - If no refresh is in progress, start a single refresh request (our “one future”).
- If a refresh is already in progress, wait for that same future.
- Once the refresh completes, retry the original request with the new token.
Sample Code
class OneFutureAuthInterceptor extends Interceptor {
final Dio dio;
Future<String?>? _refreshTokenFuture;
final TokenStorage tokenStorage; // a class that reads/writes tokens
OneFutureAuthInterceptor(this.dio, this.tokenStorage);
@override
Future<void> onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
final accessToken = await tokenStorage.readAccessToken();
if (accessToken != null && accessToken.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $accessToken';
}
handler.next(options);
}
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
if (_isUnauthorized(err) && _shouldRefresh(err.requestOptions)) {
// Attempt refresh if not already happening
_refreshTokenFuture ??= _refreshAccessToken();
final newToken = await _refreshTokenFuture;
if (newToken != null) {
// Retry the original request
final clonedRequest = _retryRequest(err.requestOptions, newToken);
try {
final response = await dio.fetch(clonedRequest);
return handler.resolve(response);
} catch (e) {
return handler.next(e as DioException);
}
}
// If refresh fails, newToken == null => pass the 401 up
}
return handler.next(err);
}
bool _isUnauthorized(DioException err) {
return err.response?.statusCode == 401;
}
bool _shouldRefresh(RequestOptions requestOptions) {
// Avoid refreshing again if it's the refresh token call
return !requestOptions.path.contains('/refresh');
}
RequestOptions _retryRequest(RequestOptions requestOptions, String newToken) {
final newHeaders = Map<String, dynamic>.from(requestOptions.headers);
newHeaders['Authorization'] = 'Bearer $newToken';
return requestOptions.copyWith(headers: newHeaders);
}
Future<String?> _refreshAccessToken() async {
try {
// Call your /refresh endpoint
// final response = await Dio().post('https://your.api/refresh', data: {...});
// final newAccessToken = response.data['accessToken'];
final newAccessToken = 'FAKE_NEW_TOKEN';
await tokenStorage.saveAccessToken(newAccessToken);
return newAccessToken;
} catch (e) {
// If fail, remove token or force user to re-log
await tokenStorage.clearTokens();
return null;
} finally {
// Allow future refresh attempts next time 401 is encountered
_refreshTokenFuture = null;
}
}
}
How It Works
-
_refreshTokenFuture ensures only one refresh call is triggered even if multiple requests fail with
401
simultaneously. - Once the refresh request completes, any other request that was also waiting for a new token will just
await
the same future.
Pros
- Concise code: “One Future” is straightforward and easy to follow.
-
No complicated data structures: We rely on a single
_refreshTokenFuture
.
Cons
-
Concurrency edge cases: If 20 requests simultaneously get a
401
, each one callsonError
, you must be careful about setting_refreshTokenFuture
quickly to avoid double refresh attempts. -
No “batch control”: Each request effectively retries itself. If you want to reorder, skip, or handle certain requests differently, you’ll do it in multiple places (
onError
for each request). - All or nothing: If the refresh fails, every request that was waiting sees the error at once.
For many small to moderate apps, “One Future” is a nice balance of simplicity and concurrency control.
3) Drawbacks & the Flag + Queue Optimization
While “One Future” works well for moderate concurrency, it can get messy if you:
- Fire lots of parallel API calls at once.
- Need to conditionally drop or batch certain calls.
- Want advanced concurrency controls, e.g. reordering requests or limiting the maximum concurrency.
In that scenario, the “One Future” approach can cause:
-
Repetitive Logic: Each request that hits
onError(401)
re-applies the same “clone & retry” code. If you need special handling (like changing request parameters before retry), that code can get scattered in multiple places. -
Difficult “group management”: Suppose you want to say, “Cancel half of the requests if the user is no longer on that screen,” or “Retry these requests in a specific sequence.” You end up patching code in each individual
onError
.
The Flag + Queue Approach
Solution: Use a flag (like _isRefreshing
) and a queue (_pendingRequests
) to store all failing requests in one place. Then:
- On
401
, push that request into_pendingRequests
. - If
_isRefreshing == false
, start the refresh flow; otherwise, keep queueing more requests. - After refreshing, loop through
_pendingRequests
and retry them all in one centralized method. - If refresh fails, fail them all at once and log out the user.
Yes, it’s more code to set up. But it:
- Centralizes concurrency. You see exactly which requests are pending.
- Lets you handle “group logic” easily (like partial cancellation, request ordering, or “one big retry batch”).
- Minimizes race conditions because the moment you set
_isRefreshing = true
, you know no second refresh will start.
Quick Snippet
class AuthInterceptorQueue extends Interceptor {
bool _isRefreshing = false;
final List<_PendingRequest> _pendingRequests = [];
// ...
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
if (_isUnauthorized(err)) {
_pendingRequests.add(_PendingRequest(err.requestOptions, Completer<Response>()));
if (!_isRefreshing) {
_isRefreshing = true;
final refreshed = await _refreshToken();
if (refreshed) {
await _retryAllRequests();
} else {
_failAllRequests(err);
}
_isRefreshing = false;
}
// The request that triggered 401 waits on a completer
try {
final last = _pendingRequests.last;
final response = await last.completer.future;
return handler.resolve(response);
} catch (e) {
return handler.next(e as DioException);
}
}
return super.onError(err, handler);
}
// ...
}
If you’re curious about the full example, check out other resources or see the “Flag + Queue” approach in detail. In many large apps, it’s a safer concurrency strategy.
Conclusion
We’ve walked through three authentication flows in Flutter with Dio:
- Only Access Token: Simplest but forces re-login on token expiration.
- Refresh Flow Using “One Future”: Short code, minimal overhead, good for moderate concurrency.
- Shortcomings & “Flag + Queue”: More code but handles concurrency thoroughly, letting you batch or reorder requests.
Which approach you choose depends on your app’s complexity and concurrency needs:
- For a small or medium-scale app, “One Future” might be perfect.
- For heavy concurrency or advanced request management, a “Flag + Queue” approach might give you better control.
Either way, placing your token logic inside a Dio interceptor is a clean design, isolating authentication concerns from the rest of your app. Happy coding!
Further Reading
- Dio GitHub
- Flutter Secure Storage for token security.
- JWT best practices for properly handling token lifecycles.
That’s it! If you have any questions or suggestions, please drop a comment below. Thanks for reading!
Top comments (0)