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






Comments
Loading comments…