Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124

Managing user sessions securely is a critical requirement for modern mobile applications. When building an app with Flutter, handling JSON Web Tokens (JWT) correctly ensures that users stay authenticated without compromising security. A robust implementation requires attaching access tokens to protected requests and using a secure mechanism to refresh expired tokens seamlessly.
This guide walks you through setting up secure token storage, attaching access tokens via HTTP interceptors, and handling token refreshes automatically in Flutter.
| Component | Details |
| Framework | Flutter |
| HTTP Client | Dio Package |
| Secure Storage | Flutter Secure Storage |
| Target Flow | JWT Access & Refresh Token Rotation |
async/await)Open your pubspec.yaml file and add the required packages for HTTP requests and encrypted local storage:
YAML
dependencies:
flutter:
sdk: flutter
dio: ^5.4.0
flutter_secure_storage: ^9.0.0
Run flutter pub get in your terminal to fetch the packages. flutter_secure_storage ensures tokens are stored securely using Keychain on iOS and EncryptedSharedPreferences on Android.
Create a helper class to handle saving, reading, and deleting tokens safely from device storage.
Dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStorage {
static const _secureStorage = FlutterSecureStorage();
static const _accessTokenKey = 'ACCESS_TOKEN';
static const _refreshTokenKey = 'REFRESH_TOKEN';
static Future<void> saveTokens({
required String accessToken,
required String refreshToken,
}) async {
await _secureStorage.write(key: _accessTokenKey, value: accessToken);
await _secureStorage.write(key: _refreshTokenKey, value: refreshToken);
}
static Future<String?> getAccessToken() async {
return await _secureStorage.read(key: _accessTokenKey);
}
static Future<String?> getRefreshToken() async {
return await _secureStorage.read(key: _refreshTokenKey);
}
static Future<void> clearTokens() async {
await _secureStorage.delete(key: _accessTokenKey);
await _secureStorage.delete(key: _refreshTokenKey);
}
}
The Dio package allows you to intercept outgoing requests and incoming errors. Using QueuedInterceptorsWrapper prevents race conditions by queuing incoming HTTP requests while a token refresh is in progress.
If an API call returns a 401 Unauthorized status code, the interceptor will automatically request a new access token using the refresh token, retry the failed request, or clear storage if the refresh token has expired.
Dart
import 'package:dio/dio.dart';
import 'token_storage.dart';
class DioClient {
static final Dio dio = Dio(
BaseOptions(
baseUrl: 'https://api.yourdomain.com/v1/',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
),
);
static void setupInterceptors() {
dio.interceptors.add(
QueuedInterceptorsWrapper(
onRequest: (options, handler) async {
// Attach Access Token to header if available
final accessToken = await TokenStorage.getAccessToken();
if (accessToken != null) {
options.headers['Authorization'] = 'Bearer $accessToken';
}
return handler.next(options);
},
onError: (DioException error, handler) async {
// Check if error is due to an expired access token (401 Unauthorized)
if (error.response?.statusCode == 401) {
final refreshToken = await TokenStorage.getRefreshToken();
if (refreshToken != null) {
try {
// Request a new access token using an isolated Dio instance
final refreshResponse = await Dio().post(
'https://api.yourdomain.com/v1/auth/refresh',
data: {'refreshToken': refreshToken},
);
final newAccessToken = refreshResponse.data['accessToken'];
final newRefreshToken = refreshResponse.data['refreshToken'];
// Save new tokens to encrypted storage
await TokenStorage.saveTokens(
accessToken: newAccessToken,
refreshToken: newRefreshToken,
);
// Update original request headers with new access token
final options = error.requestOptions;
options.headers['Authorization'] = 'Bearer $newAccessToken';
// Retry original request
final clonedResponse = await dio.fetch(options);
return handler.resolve(clonedResponse);
} catch (refreshError) {
// Refresh token is expired or invalid: clear session
await TokenStorage.clearTokens();
return handler.reject(error);
}
}
}
return handler.next(error);
},
),
);
}
}
To activate the interceptor when your application starts, call setupInterceptors() inside your main() function before launching the UI:
Dart
import 'package:flutter/material.dart';
import 'dio_client.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Dio interceptors
DioClient.setupInterceptors();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(child: Text('Flutter JWT Auth Setup Complete')),
),
);
}
}
By combining flutter_secure_storage with Dio’s QueuedInterceptorsWrapper, your Flutter application can seamlessly maintain user sessions, handle token rotation securely, and execute background request retries without interrupting the user experience.