The app resumes from background. The home screen fires five requests at once: profile, feed, notifications, cart, feature flags. The access token expired twenty minutes ago, so all five come back 401. Two seconds later the user is looking at the login screen, and nobody touched a logout button. In our code audits this is one of the most common Flutter token refresh bugs we find, and it almost always has the same two causes: a Dio interceptor that refreshes once per failed request, and a backend that is doing exactly what its security settings tell it to do.

The interceptor shape we keep finding

Here’s the version that shows up in codebase after codebase, usually copied from a tutorial and working fine in development:

class AuthInterceptor extends Interceptor {
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      final newToken = await _authApi.refresh(await _store.refreshToken());
      await _store.save(newToken);
      err.requestOptions.headers['Authorization'] = 'Bearer ${newToken.access}';
      return handler.resolve(await _dio.fetch(err.requestOptions));
    }
    handler.next(err);
  }
}

With one request in flight, this works. With five, you get five independent calls to onError, each reading the same refresh token from storage and each firing its own POST /auth/refresh. Nothing coordinates them. In development you rarely see several requests expire at the same moment, which is why this gets through QA and then shows up in production as “random logouts” that nobody can reproduce.

Even without any backend strictness, you now have a last-write-wins race on secure storage. Whichever refresh response lands last gets persisted, and it may not be the pair the server considers current.

Why refresh-token rotation turned a race into a logout

The backend in these audits usually has refresh-token rotation turned on, often with reuse detection. That’s the correct default. Rotation means every refresh call invalidates the refresh token it was given and issues a new one. Reuse detection means that if an already-used refresh token is presented again, the server assumes it was stolen and revokes the whole token family.

Walk the five requests through that:

  1. Request A sends refresh token RT0. The server issues RT1 and marks RT0 as used.
  2. Request B, a few milliseconds behind, also sends RT0.
  3. The server sees a reused token, treats it as a replay attack, and revokes the family, including the RT1 it just issued.
  4. Requests C through E fail too. The client’s “refresh failed, clear the session” branch runs, and the user is logged out.

Your own client did exactly what an attacker replaying a stolen token would do, and the server responded the way it’s designed to. Turning off reuse detection “fixes” the symptom by throwing away the security property, and we’ve seen teams do that. The actual fix belongs on the client.

The single-flight refresh lock for Flutter token refresh

The rule is simple: at most one refresh call can be in flight at any time, and every 401 that arrives while it runs waits for that same result. In Dart, a shared Future is enough:

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

  final Dio _dio;
  final Dio _refreshDio; // separate instance, no AuthInterceptor attached
  final TokenStore _store;
  Future<String>? _refreshing;

  Future<String> _refreshOnce() =>
      _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);

  Future<String> _doRefresh() async {
    final res = await _refreshDio.post('/auth/refresh',
        data: {'refresh_token': await _store.refreshToken()});
    await _store.save(res.data); // persist before anyone retries
    return res.data['access_token'] as String;
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    final opts = err.requestOptions;
    if (err.response?.statusCode != 401 || opts.extra['retried'] == true) {
      return handler.next(err);
    }
    try {
      final current = await _store.accessToken();
      final sent = opts.headers['Authorization'];
      // Another request already refreshed while this one was in flight.
      final token = sent != 'Bearer $current' ? current : await _refreshOnce();
      opts.headers['Authorization'] = 'Bearer $token';
      opts.extra['retried'] = true;
      handler.resolve(await _dio.fetch(opts));
    } on DioException catch (e) {
      handler.next(e);
    }
  }
}

A few details here matter more than they look:

  • The stale-header check. A request that went out with the old token can get its 401 back after the refresh has already finished. Without comparing the token it sent against the stored one, that request would start a second refresh with the now-rotated RT1. That second call is harmless but wasted, and if _store.save hasn’t landed yet it becomes a reuse event again.
  • A separate Dio for refresh. If the refresh call goes through the same interceptor and comes back 401, it waits on the Future it is supposed to complete. That’s a deadlock, and it presents as a hung spinner rather than an error.
  • The retried flag. This caps each request at one retry, so a backend that returns 401 for reasons unrelated to expiry (wrong scope, revoked user) can’t send you into a loop.
  • One failure, one logout. Because every waiter shares the same Future, a failed refresh raises one error that you can map to one session-cleared event, not five competing navigations to the login route.

Dio’s QueuedInterceptor is sometimes suggested as the fix. It serializes onError calls, which prevents concurrent refreshes, but each queued 401 will still refresh in turn unless you add the same stale-token check. Serialization without deduplication just turns five parallel reuse events into five sequential ones.

One more gap: the lock is per isolate. If a background isolate (push handlers, background fetch, workmanager tasks) makes authenticated calls with its own Dio instance, it can race the UI isolate. Route those calls through the main isolate, or keep refresh-token ownership on one side.

What to change on the backend

Keep rotation and keep reuse detection. Add a short grace window. Most identity providers support one (Auth0 calls it the reuse interval), and if you run your own auth service in Node.js or Python it’s a few lines. Within a few seconds of rotation, presenting the previous refresh token returns the same new pair instead of revoking the family. That covers flaky networks where the client never received the first refresh response, which a client-side lock can’t fix. Outside the window, reuse still means compromise.

Also log reuse-detection events with a device or session identifier. If the count spikes after an app release, the cause is almost certainly a client regression, not an attack, and those logs will tell you which build introduced it.

This bug rarely shows up in a demo. It appears once real users background the app over lunch, which is why we look for it early in every Flutter code audit. At orithLabs, the patterns we recommend come from apps we’ve shipped ourselves, like Crumb Count and Kompete, where the client and the auth backend had to work together under real usage. If your app has “random logouts” that nobody can reproduce, check the interceptor first, and we’re happy to take a look with you.