How to Handle JWT Authentication and Refresh Tokens in Flutter
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.
Quick Overview
| Component | Details |
| Framework | Flutter |
| HTTP Client | Dio Package |
| Secure Storage | Flutter Secure Storage |
| Target Flow | JWT Access & Refresh Token Rotation |
Prerequisites
- Flutter SDK (Version 3.0 or higher)
- Basic understanding of asynchronous programming in Dart (
async/await) - An active backend API supporting JWT authentication and refresh endpoints
Step 1: Installing Required Dependencies
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.
Step 2: Creating a Secure Token Storage Service
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);
}
}
Step 3: Implementing Dio Interceptor for Automatic Token Refresh
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);
},
),
);
}
}
Step 4: Initializing DioClient in Your App
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')),
),
);
}
}
Summary
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.
