Skip to content
ExponaBLOG
FLUTTER · DAY 037 / 100

Auth interceptors in Dio: a Flutter guide for developers

Attach tokens and refresh them automatically.

Expona Admin12 min read

Auth interceptors in Dio: automatic token management for Flutter apps

When you're building production-grade mobile apps, authentication never stays simple. Your access tokens expire, refresh tokens need rotating, and users expect seamless experiences without seeing login screens mid-session. Auth interceptors in Dio solve this by sitting between your app and API, transparently handling tokens and retries without cluttering your business logic.

Over my five years shipping production Flutter apps—from ride-hailing platforms to trading systems—I've learned that proper token management makes the difference between a flaky app and one users trust. Today we'll build a robust auth interceptor that handles the real-world messiness: attaching tokens, refreshing on 401, queuing concurrent requests, and logging out gracefully when things break.

The problem: why we need interceptors

Without interceptors, every API call becomes this:

dart
final token = await _tokenStorage.getAccessToken();
final headers = {'Authorization': 'Bearer $token'};
final response = await dio.get('/profile', options: Options(headers: headers));

Multiply that across 50 endpoints. Add token refresh logic, error handling, and retry loops. Your code explodes. More importantly, if five requests fire simultaneously and the token expires mid-request, you're either refreshing five times (wasteful) or handling race conditions manually (error-prone).

Dio interceptors let you centralize all of this in one place. Token attachment, refresh logic, request queueing, and logout—all handled transparently.

Add the access token to every request

The first responsibility of an auth interceptor is attaching your access token to every outbound request. You'll create a Dio interceptor that reads the token from secure storage and adds it to the Authorization header.

Start with a simple service to store and retrieve tokens:

dart
abstract class TokenStorage {
  Future getAccessToken();
  Future getRefreshToken();
  Future saveTokens(String accessToken, String refreshToken);
  Future clearTokens();
}

class SecureTokenStorage implements TokenStorage {
  final FlutterSecureStorage _storage;

  SecureTokenStorage(this._storage);

  @override
  Future getAccessToken() => _storage.read(key: 'access_token');

  @override
  Future getRefreshToken() => _storage.read(key: 'refresh_token');

  @override
  Future saveTokens(String accessToken, String refreshToken) async {
    await Future.wait([
      _storage.write(key: 'access_token', value: accessToken),
      _storage.write(key: 'refresh_token', value: refreshToken),
    ]);
  }

  @override
  Future clearTokens() async {
    await Future.wait([
      _storage.delete(key: 'access_token'),
      _storage.delete(key: 'refresh_token'),
    ]);
  }
}

Now create an interceptor that attaches the token before any request leaves your app:

dart
class AuthInterceptor extends Interceptor {
  final TokenStorage _tokenStorage;

  AuthInterceptor(this._tokenStorage);

  @override
  Future onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final token = await _tokenStorage.getAccessToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }
}

Register this interceptor when creating your Dio instance:

dart
final dio = Dio();
dio.interceptors.add(AuthInterceptor(tokenStorage));

Now every request automatically includes your token. No manual header management. If the token is null (user not logged in), the header simply isn't set—your backend handles that with a 401 response.

On 401, refresh once and retry

Tokens expire. When your API returns 401 Unauthorized, it's time to refresh. The key insight: you don't immediately log the user out. Instead, you try to refresh the access token using your refresh token, then retry the original request.

Extend your interceptor to handle 401 responses:

dart
class AuthInterceptor extends Interceptor {
  final TokenStorage _tokenStorage;
  final Dio _dio;

  AuthInterceptor(this._tokenStorage, this._dio);

  @override
  Future onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final token = await _tokenStorage.getAccessToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  Future onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    if (err.response?.statusCode == 401) {
      final refreshed = await _refreshAccessToken();
      if (refreshed) {
        // Retry the original request with the new token
        final retryOptions = err.requestOptions;
        final token = await _tokenStorage.getAccessToken();
        retryOptions.headers['Authorization'] = 'Bearer $token';
        
        try {
          final response = await _dio.request(
            retryOptions.path,
            options: Options(
              method: retryOptions.method,
              headers: retryOptions.headers,
            ),
            data: retryOptions.data,
            queryParameters: retryOptions.queryParameters,
          );
          handler.resolve(response);
        } catch (e) {
          handler.next(err);
        }
      } else {
        // Refresh failed, propagate the 401
        handler.next(err);
      }
    } else {
      handler.next(err);
    }
  }

  Future _refreshAccessToken() async {
    try {
      final refreshToken = await _tokenStorage.getRefreshToken();
      if (refreshToken == null) return false;

      final response = await _dio.post(
        '/auth/refresh',
        data: {'refresh_token': refreshToken},
        options: Options(
          headers: {'Authorization': null}, // Don't attach our interceptor
        ),
      );

      final newAccessToken = response.data['access_token'];
      final newRefreshToken = response.data['refresh_token'];
      
      await _tokenStorage.saveTokens(newAccessToken, newRefreshToken);
      return true;
    } catch (e) {
      return false;
    }
  }
}

This flow is bulletproof for single requests. When a 401 hits, you refresh once, then retry. If refresh succeeds, the original request goes through with the new token. If refresh fails, you return the 401 to let your app handle logout.

Queue requests during refresh

Here's where real-world complexity appears: imagine five requests fire simultaneously, all get 401 responses, and all try to refresh. You'll end up calling your refresh endpoint five times—wasteful, potentially rate-limited, and racing.

The solution is a request queue. While one refresh is in flight, all other failed requests wait. Once refresh completes, retry them all:

dart
class AuthInterceptor extends Interceptor {
  final TokenStorage _tokenStorage;
  final Dio _dio;

  bool _isRefreshing = false;
  late Queue> _requestQueue;

  AuthInterceptor(this._tokenStorage, this._dio) {
    _requestQueue = Queue();
  }

  @override
  Future onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final token = await _tokenStorage.getAccessToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  Future onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    if (err.response?.statusCode == 401) {
      if (_isRefreshing) {
        // Another refresh is already in flight, queue this request
        final completer = Completer();
        _requestQueue.add(completer);
        
        try {
          await completer.future;
          // Refresh succeeded, retry the original request
          final retryOptions = err.requestOptions;
          final token = await _tokenStorage.getAccessToken();
          retryOptions.headers['Authorization'] = 'Bearer $token';
          
          final response = await _dio.request(
            retryOptions.path,
            options: Options(
              method: retryOptions.method,
              headers: retryOptions.headers,
            ),
            data: retryOptions.data,
            queryParameters: retryOptions.queryParameters,
          );
          handler.resolve(response);
        } catch (e) {
          handler.next(err);
        }
      } else {
        _isRefreshing = true;
        
        try {
          final refreshed = await _refreshAccessToken();
          if (refreshed) {
            // Retry the original request
            final retryOptions = err.requestOptions;
            final token = await _tokenStorage.getAccessToken();
            retryOptions.headers['Authorization'] = 'Bearer $token';
            
            final response = await _dio.request(
              retryOptions.path,
              options: Options(
                method: retryOptions.method,
                headers: retryOptions.headers,
              ),
              data: retryOptions.data,
              queryParameters: retryOptions.queryParameters,
            );
            handler.resolve(response);
          } else {
            handler.next(err);
          }
        } finally {
          _isRefreshing = false;
          _processQueue();
        }
      }
    } else {
      handler.next(err);
    }
  }

  void _processQueue() {
    while (_requestQueue.isNotEmpty) {
      _requestQueue.removeFirst().complete();
    }
  }

  Future _refreshAccessToken() async {
    try {
      final refreshToken = await _tokenStorage.getRefreshToken();
      if (refreshToken == null) return false;

      final response = await _dio.post(
        '/auth/refresh',
        data: {'refresh_token': refreshToken},
        options: Options(
          headers: {'Authorization': null},
        ),
      );

      final newAccessToken = response.data['access_token'];
      final newRefreshToken = response.data['refresh_token'];
      
      await _tokenStorage.saveTokens(newAccessToken, newRefreshToken);
      return true;
    } catch (e) {
      return false;
    }
  }
}

The queue uses Completers. When a second 401 arrives and a refresh is already running, we create a Completer and add it to the queue. The first request refreshes, then calls _processQueue() to complete all waiting Completers, which unblocks the queued requests to retry.

This is critical for high-traffic apps. I've seen ride-hailing apps fire 15+ requests simultaneously when opening the driver's dashboard. Without queueing, you'd refresh 15 times.

Log out on refresh failure

Not every token can be refreshed. If your refresh token is invalid or expired, the backend returns 401 and there's nothing left to do. Your user's session is dead. Time to log them out.

Create an authentication service that orchestrates login, logout, and token refresh:

dart
class AuthService {
  final TokenStorage _tokenStorage;
  final Dio _dio;

  final _logoutStream = StreamController.broadcast();
  Stream get onLogout => _logoutStream.stream;

  AuthService(this._tokenStorage, this._dio);

  Future logout() async {
    await _tokenStorage.clearTokens();
    _logoutStream.add(null);
  }
}

Pass the AuthService to your interceptor and call logout when refresh fails:

dart
class AuthInterceptor extends Interceptor {
  final TokenStorage _tokenStorage;
  final Dio _dio;
  final AuthService _authService;

  bool _isRefreshing = false;
  late Queue> _requestQueue;

  AuthInterceptor(this._tokenStorage, this._dio, this._authService) {
    _requestQueue = Queue();
  }

  // ... onRequest and most of onError stays the same ...

  @override
  Future onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    if (err.response?.statusCode == 401) {
      if (_isRefreshing) {
        final completer = Completer();
        _requestQueue.add(completer);
        
        try {
          await completer.future;
          final retryOptions = err.requestOptions;
          final token = await _tokenStorage.getAccessToken();
          retryOptions.headers['Authorization'] = 'Bearer $token';
          
          final response = await _dio.request(
            retryOptions.path,
            options: Options(
              method: retryOptions.method,
              headers: retryOptions.headers,
            ),
            data: retryOptions.data,
            queryParameters: retryOptions.queryParameters,
          );
          handler.resolve(response);
        } catch (e) {
          handler.next(err);
        }
      } else {
        _isRefreshing = true;
        
        try {
          final refreshed = await _refreshAccessToken();
          if (refreshed) {
            final retryOptions = err.requestOptions;
            final token = await _tokenStorage.getAccessToken();
            retryOptions.headers['Authorization'] = 'Bearer $token';
            
            final response = await _dio.request(
              retryOptions.path,
              options: Options(
                method: retryOptions.method,
                headers: retryOptions.headers,
              ),
              data: retryOptions.data,
              queryParameters: retryOptions.queryParameters,
            );
            handler.resolve(response);
          } else {
            // Refresh failed, log out the user
            await _authService.logout();
            handler.next(err);
          }
        } finally {
          _isRefreshing = false;
          _processQueue();
        }
      }
    } else {
      handler.next(err);
    }
  }

  Future _refreshAccessToken() async {
    try {
      final refreshToken = await _tokenStorage.getRefreshToken();
      if (refreshToken == null) return false;

      final response = await _dio.post(
        '/auth/refresh',
        data: {'refresh_token': refreshToken},
        options: Options(
          headers: {'Authorization': null},
        ),
      );

      final newAccessToken = response.data['access_token'];
      final newRefreshToken = response.data['refresh_token'];
      
      await _tokenStorage.saveTokens(newAccessToken, newRefreshToken);
      return true;
    } catch (e) {
      return false;
    }
  }

  void _processQueue() {
    while (_requestQueue.isNotEmpty) {
      _requestQueue.removeFirst().complete();
    }
  }
}

In your main app, listen to the logout stream and navigate to the login screen:

dart
class MyApp extends StatelessWidget {
  final AuthService authService;

  const MyApp(this.authService);

  @override
  Widget build(BuildContext context) {
    return StreamBuilder(
      stream: authService.onLogout,
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          Navigator.of(context).pushNamedAndRemoveUntil('/login', (_) => false);
        }
        return MaterialApp(
          home: HomePage(),
        );
      },
    );
  }
}

Now when a refresh fails, users are silently logged out and redirected to login. No manual intervention needed.

Common mistakes

  • Creating a new Dio instance in the interceptor. If you call _dio.post() inside your interceptor using a fresh Dio(), you bypass the interceptor itself and create infinite loops. Always use the same Dio instance or explicitly skip interceptors on refresh calls.
  • Not disabling interceptors on the refresh endpoint. When refreshing, don't attach your auth interceptor or you'll end up trying to refresh while refreshing. Use Options(headers: {'Authorization': null}) to skip it.
  • Forgetting to clear tokens on logout. If you call logout() but leave tokens in storage, your app will keep using stale credentials. Always clear in TokenStorage and destroy sensitive data.
  • Retrying non-idempotent requests blindly. POST, PATCH, and DELETE requests can have side effects. Retrying them after refresh might duplicate transactions. Check your API design or add idempotency keys.
  • Not testing concurrent requests. Queue logic is easy to get wrong. Fire 10+ requests simultaneously in your tests to ensure only one refresh runs.

Key takeaways

  • Auth interceptors centralize token management, eliminating boilerplate across every API call in your app.
  • Attach the access token in the onRequest phase before any request leaves your Dio client.
  • On 401, attempt to refresh the token once using your refresh token; if successful, retry the original request.
  • Queue incoming requests while a refresh is in flight to prevent multiple refresh calls and race conditions.
  • When refresh fails (invalid or expired refresh token), log the user out and clear all stored credentials immediately.
  • Always use the same Dio instance in the interceptor and disable interceptors explicitly on refresh calls to avoid recursion.
  • Test your implementation with concurrent requests to verify queueing and token handling under load.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles