The bug report usually says something like: “Some users get logged out at random. We can’t reproduce it.” QA can’t reproduce it on Wi-Fi. The backend team says the tokens are valid. Crash reporting shows nothing, because nothing crashed. The session just ended.

We find this in almost every Flutter codebase we audit. It isn’t random. It’s a race condition in the Flutter token refresh Dio interceptor, and refresh-token rotation on the server turns it into a logout.

How the race happens

Suppose the access token has a 15-minute lifetime. The user leaves the app in the background for 20 minutes and then opens it. The home screen mounts and fires four requests in parallel: profile, feed, notifications and feature flags. All four go out with the expired access token, and all four come back 401.

The typical interceptor looks roughly like this:

onError: (err, handler) async {
  if (err.response?.statusCode == 401) {
    final newToken = await refreshToken(); // called 4 times
    err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
    return handler.resolve(await dio.fetch(err.requestOptions));
  }
  handler.next(err);
}

Each 401 runs refreshToken() on its own, so four refresh calls go out, all carrying the same refresh token. Here is what the server does with them:

  1. Refresh #1 arrives. The server issues new tokens, marks the old refresh token as used and returns a new pair.
  2. Refresh #2 arrives with the refresh token that was just used. With rotation and reuse detection enabled, which the OAuth security best-practice guidance recommends, the server treats this as a stolen token being replayed and revokes the whole token family, including the pair it issued a few milliseconds earlier.
  3. Refreshes #3 and #4 fail. The client’s “refresh failed” path clears storage and sends the user to the login screen.

Timing is why it looks random. On a fast connection the first refresh sometimes finishes before the other 401s come back, and that run survives. On a slow cellular network all four overlap. Whether a user gets logged out depends on latency, how many requests a screen fires at once and how long the app sat in the background. That’s why it never shows up on a developer’s desk.

Switching to Dio’s QueuedInterceptor doesn’t fix it on its own. That class runs the error handlers one after another instead of concurrently, but each queued handler still calls refresh unless it first checks whether someone else already got a new token. With a queue, the refreshes happen in sequence instead of in parallel, and the server revokes the session all the same.

The fix: a single-flight Flutter token refresh Dio interceptor

The rule: at most one refresh request is in flight at any moment. Any request that hits a 401 while a refresh is running waits on the same future. The first request to arrive creates a Completer, and every later caller gets its future.

class AuthInterceptor extends Interceptor {
  AuthInterceptor(this._dio, this._refreshDio, this._store);

  final Dio _dio;
  final Dio _refreshDio; // separate instance, no auth interceptor
  final TokenStore _store;
  Completer<String>? _inFlight;

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = _store.accessToken;
    if (token != null) options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  }

  @override
  Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
    final req = err.requestOptions;
    if (err.response?.statusCode != 401 || req.extra['retried'] == true) {
      return handler.next(err);
    }
    try {
      // If the token changed since this request left, someone already refreshed.
      final stale = req.headers['Authorization'] != 'Bearer ${_store.accessToken}';
      final token = stale ? _store.accessToken! : await _refresh();
      req.headers['Authorization'] = 'Bearer $token';
      req.extra['retried'] = true;
      handler.resolve(await _dio.fetch(req));
    } catch (e) {
      handler.next(e is DioException ? e : DioException(requestOptions: req, error: e));
    }
  }

  Future<String> _refresh() {
    final existing = _inFlight;
    if (existing != null) return existing.future;

    final c = _inFlight = Completer<String>();
    () async {
      try {
        final res = await _refreshDio.post('/auth/refresh',
            data: {'refresh_token': _store.refreshToken});
        await _store.save(res.data['access_token'], res.data['refresh_token']);
        c.complete(res.data['access_token'] as String);
      } catch (e, st) {
        c.completeError(e, st);
      } finally {
        _inFlight = null;
      }
    }();
    return c.future;
  }
}

A few details in there matter more than they look:

  • The staleness check. A 401 can come back after the refresh has already finished and _inFlight has been cleared. Without the header comparison, that late 401 would start a second refresh using the refresh token that was just rotated, and you’d have the original bug again. Comparing the token the request was sent with against the current one closes that gap.
  • A separate Dio for the refresh call. If the refresh request goes through the same interceptor, a 401 from the refresh endpoint calls back into onError, which waits on _inFlight, which is waiting on that same request. It deadlocks.
  • The retried flag. A retried request that still gets a 401 fails with that error. It doesn’t loop.
  • Clearing _inFlight in finally. If a refresh fails, every waiter gets the error once, and the next 401 can try again. Nobody waits forever on a completer that will never complete.
  • Persisting before completing. _store.save runs before c.complete, so retried requests and onRequest both see the new pair.

This lock is per isolate. If you refresh tokens from a background isolate as well, for example in a WorkManager task, you need a lock that works across processes or a single owner for the refresh. That’s a bigger conversation, and it comes up in audits more often than you’d expect.

The one-line check

You can check whether your app has this bug without reading any client code. Look for the same user refreshing more than once within the same second. With JSON access logs, a timestamp in ISO format and a user or session ID on each line:

jq -r 'select(.path=="/auth/refresh") | "\(.user_id) \(.ts[0:19])"' access.log | sort | uniq -d | head

Change the field names to match your log format. If this prints anything, clients are sending concurrent refreshes. If your server rotates refresh tokens, each of those lines is probably a session your users lost. If you’d rather check on the client, log when a refresh starts and when it finishes. Two “start” lines with no “finish” between them means the same bug.

A short grace window on the server, where the previous refresh token is accepted for a few seconds after rotation, makes this less painful. Treat it as a second layer of protection, though. The client should only ever send one refresh at a time.

At orithLabs, this kind of bug is a big part of what our audits turn up. It doesn’t come from a missing feature. It comes from code that works in the happy path and fails under concurrency, only on real networks. If users of your app report being logged out “sometimes,” run the log check above first. If the cause turns out to be something else, we’re happy to take a look at it with you. Auth layers like this one are a routine part of our mobile development work.