DEV Community

Twilight
Twilight

Posted on

1 1

Mastering Auth in Flutter with Dio: From Simple Access Tokens to a Refresh Flow

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:

  1. A simple app with only an access token: Suitable for short-lived demos or backends that never expire tokens.
  2. A refresh token flow with “One Future”: Ensures only one refresh request is triggered, but can get clunky with concurrency.
  3. 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. In onRequest, attach the current access token.
  2. If a request fails with 401, check if we’re already refreshing.
  3. If no refresh is in progress, start a single refresh request (our “one future”).
  4. If a refresh is already in progress, wait for that same future.
  5. 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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 calls onError, 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:

  1. 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.
  2. 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:

  1. On 401, push that request into _pendingRequests.
  2. If _isRefreshing == false, start the refresh flow; otherwise, keep queueing more requests.
  3. After refreshing, loop through _pendingRequests and retry them all in one centralized method.
  4. 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);
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Only Access Token: Simplest but forces re-login on token expiration.
  2. Refresh Flow Using “One Future”: Short code, minimal overhead, good for moderate concurrency.
  3. 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

That’s it! If you have any questions or suggestions, please drop a comment below. Thanks for reading!

Sentry blog image

The Visual Studio App Center’s retiring

But sadly….you’re not. See how to make the switch to Sentry for all your crash reporting needs.

Read more

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Immerse yourself in a wealth of knowledge with this piece, supported by the inclusive DEV Community—every developer, no matter where they are in their journey, is invited to contribute to our collective wisdom.

A simple “thank you” goes a long way—express your gratitude below in the comments!

Gathering insights enriches our journey on DEV and fortifies our community ties. Did you find this article valuable? Taking a moment to thank the author can have a significant impact.

Okay