Authentication #
User authentication is a fundamental security pillar present in almost every production-grade mobile app. Through authentication, the system can verify user identity, protect sensitive data confidentiality, and ensure every request entering the API server comes from a legitimate source.
In modern API architecture, the most common authentication protocol is the token-based system using JWT (JSON Web Token). Managing an authentication system in Flutter professionally requires you to master various complex technical aspects: storing tokens encrypted in the device’s local storage, automatically injecting tokens into every request header, detecting expired access tokens, performing automatic token refresh in the background without disrupting user comfort, and protecting page routes (route guarding).
In this guide, we’ll thoroughly unpack how to implement a JWT token authentication system in Flutter comprehensively. We’ll learn the JWT workflow, utilize the operating system’s secure storage area, create a request queue interceptor, structure authentication state management, and integrate Google Sign-In as a third-party OAuth.
JWT Authentication Workflow #
The JWT-based authentication system divides security tokens into two main types with different active periods:
- Access Token: The main token used to prove your access rights to the server. This token usually has a very short active period (e.g., 15 minutes to 1 hour) for security reasons.
- Refresh Token: A backup token used purely to request a new Access Token when the old Access Token has expired. This token is stored securely and usually has a much longer active period (e.g., 7 days to 30 days).
Here’s a sequence diagram illustrating how the JWT authentication lifecycle runs, from normal API calls, dead token detection, automatic background refresh, to transparent original request retry for the user:
sequenceDiagram
autonumber
actor User
participant UI as UI Widget
participant Client as Dio HTTP Client
participant Interceptor as Auth Interceptor
participant Server as API Backend Server
User->>UI: Click the fetch data button
UI->>Client: getProducts()
Client->>Interceptor: Check access token
Interceptor->>Server: GET /products (Header: Expired Access Token)
Server-->>Interceptor: 401 Unauthorized (Token Expired)
Note over Interceptor: Trigger Refresh Token
Interceptor->>Server: POST /auth/refresh (Body: Refresh Token)
Server-->>Interceptor: 200 OK (New Access Token & Refresh Token)
Note over Interceptor: Save New Token to Secure Storage
Note over Interceptor: Retry Original Request
Interceptor->>Server: GET /products (Header: New Access Token)
Server-->>Interceptor: 200 OK (Product List)
Interceptor-->>Client: Return Success Response
Client-->>UI: Send Product Data
UI-->>User: Display Updated DataSecure Token Storage Using flutter_secure_storage #
One of the most fatal mistakes in Flutter security is storing Access Tokens and Refresh Tokens in plain SharedPreferences. Data written to SharedPreferences is stored in unencrypted plain-text XML file format, so it can be easily read by other apps or malware on rooted Android devices.
You must use flutter_secure_storage. This library utilizes the encrypted storage area provided by the device’s operating system: Keychain on iOS and Keystore on Android, encrypted with the AES-256 algorithm.
Add the following dependency to your pubspec.yaml file:
dependencies:
flutter_secure_storage: ^9.2.2
Creating the Token Storage Service #
Let’s create a wrapper class to centralize secure token write, read, and delete operations:
// core/auth/token_storage.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStorage {
// Configuring Android-specific Shared Preferences encryption
// and iOS Keychain accessibility level
static const _secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const _keyAccessToken = 'access_token';
static const _keyRefreshToken = 'refresh_token';
static const _keyExpiresAt = 'token_expiry_time';
// 1. Storing tokens after a successful login/registration
static Future<void> saveToken({
required String accessToken,
required String refreshToken,
required int durationSeconds,
}) async {
// Calculating the absolute token expiry time in the future
final expiryTime = DateTime.now()
.add(Duration(seconds: durationSeconds))
.millisecondsSinceEpoch
.toString();
await Future.wait([
_secureStorage.write(key: _keyAccessToken, value: accessToken),
_secureStorage.write(key: _keyRefreshToken, value: refreshToken),
_secureStorage.write(key: _keyExpiresAt, value: expiryTime),
]);
}
// 2. Reading the access token
static Future<String?> getAccessToken() async {
return _secureStorage.read(key: _keyAccessToken);
}
// 3. Reading the refresh token
static Future<String?> getRefreshToken() async {
return _secureStorage.read(key: _keyRefreshToken);
}
// 4. Proactively checking expiration
static Future<bool> isTokenExpired() async {
final timeStr = await _secureStorage.read(key: _keyExpiresAt);
if (timeStr == null) return true;
final expiryTime = DateTime.fromMillisecondsSinceEpoch(int.parse(timeStr));
// Providing a 30-second time tolerance buffer.
// We request refresh before the token truly dies when the request is sent.
final bufferTime = expiryTime.subtract(const Duration(seconds: 30));
return DateTime.now().isAfter(bufferTime);
}
// 5. Verifying the availability of a valid token
static Future<bool> hasValidToken() async {
final token = await getAccessToken();
if (token == null) return false;
return !await isTokenExpired();
}
// 6. Deleting all tokens when the user logs out
static Future<void> deleteAllTokens() async {
await Future.wait([
_secureStorage.delete(key: _keyAccessToken),
_secureStorage.delete(key: _keyRefreshToken),
_secureStorage.delete(key: _keyExpiresAt),
]);
}
}
Auth Interceptor: Automatic Token Injection & Refresh #
The most challenging part of an authentication system is writing the Auth Interceptor. This interceptor handles two heavy jobs:
- Automatically injecting the
Authorization: Bearer ***header into every outgoing request (onRequest). - Intercepting
401 Unauthorizederrors from the server, quietly making the refresh token API call, storing the new token, and retrying the original request that previously failed (onError).
Solving the Infinite Loop & Request Queue Problems #
- Separate Dio Instances: You must not use the same main
Dioinstance to make the refresh token request. If you do, that refresh token request will also be intercepted by this interceptor and trigger stacked refresh calls causing an infinite loop until the app crashes. You must create a second Dio instance clean of interceptors specifically for the/auth/refreshroute. - Request Queue: If the user is loading a page that triggers 3 parallel API calls, and all three fail with a 401 error, you must not shoot the refresh token API 3 times. You must lock the first refresh process, queue the second and third requests using a
Completer, then release the queue lock after the new token is successfully obtained.
Here’s the complete, robust, and safe AuthInterceptor code:
import 'dart:async';
import 'package:dio/dio.dart';
import 'token_storage.dart';
import '../errors/exceptions.dart';
class AuthInterceptor extends Interceptor {
final Dio _mainDio;
// Isolated Dio instance without interceptors to avoid infinite loops
final Dio _refreshDio;
bool _isRefreshing = false;
// Stores the list of deferred requests while the token refresh process runs
final List<_DeferredRequest> _requestQueue = [];
AuthInterceptor(this._mainDio)
: _refreshDio = Dio(BaseOptions(baseUrl: 'https://api.ourstore.com/v1'));
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
// 1. Skip token injection if the target route is a basic auth endpoint
if (_isAuthRoute(options.path)) {
return handler.next(options);
}
// 2. Do a proactive refresh if the token is known to be expired before the request is sent
if (await TokenStorage.isTokenExpired()) {
try {
await _runRefresh();
} catch (_) {
// If the proactive refresh fails, let the request be sent and handled in onError
}
}
final token = await TokenStorage.getAccessToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
// 3. We only handle 401 errors and not those from the auth endpoint itself
if (err.response?.statusCode != 401 || _isAuthRoute(err.requestOptions.path)) {
return handler.next(err);
}
final requestOptions = err.requestOptions;
// 4. If the refresh process is already running by another request, queue this request
if (_isRefreshing) {
final requestCompleter = Completer<Response>();
_requestQueue.add(
_DeferredRequest(
options: requestOptions,
completer: requestCompleter,
),
);
try {
final retryResponse = await requestCompleter.future;
handler.resolve(retryResponse);
} catch (e) {
handler.next(err);
}
return;
}
// 5. Start the main token refresh process
_isRefreshing = true;
try {
await _runRefresh();
final newToken = await TokenStorage.getAccessToken();
// Update the original request header with the new token
requestOptions.headers['Authorization'] = 'Bearer $newToken';
// Re-run the original request
final mainResponse = await _mainDio.fetch(requestOptions);
// 6. Re-run all requests queued in the list
for (final queuedRequest in _requestQueue) {
queuedRequest.options.headers['Authorization'] = 'Bearer $newToken';
final queuedRetryResponse = await _mainDio.fetch(queuedRequest.options);
queuedRequest.completer.complete(queuedRetryResponse);
}
_requestQueue.clear();
handler.resolve(mainResponse);
} catch (e) {
// 7. If the refresh process fails (refresh token dead), clear tokens and force logout
await TokenStorage.deleteAllTokens();
for (final queuedRequest in _requestQueue) {
queuedRequest.completer.completeError(e);
}
_requestQueue.clear();
handler.next(err);
} finally {
_isRefreshing = false;
}
}
Future<void> _runRefresh() async {
final refreshToken = await TokenStorage.getRefreshToken();
if (refreshToken == null) throw UnauthorizedException();
final response = await _refreshDio.post(
'/auth/refresh',
data: {'refresh_token': refreshToken},
);
// Store the new tokens sent by the backend server
await TokenStorage.saveToken(
accessToken: response.data['access_token'],
refreshToken: response.data['refresh_token'],
durationSeconds: response.data['expires_in'],
);
}
bool _isAuthRoute(String path) {
return path.contains('/auth/login') ||
path.contains('/auth/register') ||
path.contains('/auth/refresh');
}
}
class _DeferredRequest {
final RequestOptions options;
final Completer<Response> completer;
_DeferredRequest({
required this.options,
required this.completer,
});
}
Auth State Management #
The app’s authentication status is global and must always monitor user transition conditions (whether authenticated, not logged in, loading, or experiencing errors). You use the Freezed package to structure the AuthState class neatly:
// features/auth/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/user.dart'; // Assume the User model already exists
part 'auth_state.freezed.dart';
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated(User user) = _Authenticated;
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.error(String message) = _Error;
}
Creating the Auth Notifier #
Here’s the Riverpod-based AuthNotifier implementation for managing centralized authentication interactions:
// features/auth/auth_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_state.dart';
import 'token_storage.dart';
import '../../domain/repositories/auth_repository.dart';
class AuthNotifier extends AutoDisposeAsyncNotifier<AuthState> {
@override
Future<AuthState> build() async {
// 1. Check token availability when the app first opens (startup check)
if (await TokenStorage.hasValidToken()) {
try {
final repository = ref.read(authRepositoryProvider);
final user = await repository.fetchUserProfile();
return AuthState.authenticated(user);
} catch (_) {
// If fetching profile data fails (e.g., problematic token on the server), clear storage
await TokenStorage.deleteAllTokens();
}
}
return const AuthState.unauthenticated();
}
// 2. Login action
Future<void> login(String email, String password) async {
state = const AsyncValue.data(AuthState.loading());
final repository = ref.read(authRepositoryProvider);
final result = await repository.loginEmailPassword(email, password);
result.fold(
(appException) => state = AsyncValue.data(AuthState.error(appException.message)),
(authResult) async {
await TokenStorage.saveToken(
accessToken: authResult.accessToken,
refreshToken: authResult.refreshToken,
durationSeconds: authResult.durationSeconds,
);
state = AsyncValue.data(AuthState.authenticated(authResult.user));
},
);
}
// 3. Logout action
Future<void> logout() async {
try {
final repository = ref.read(authRepositoryProvider);
await repository.serverLogout();
} finally {
// Always clear local tokens regardless of the server hit result
await TokenStorage.deleteAllTokens();
state = const AsyncValue.data(AuthState.unauthenticated());
}
}
// 4. Forced logout when the token is truly completely expired
void forceLogout() {
TokenStorage.deleteAllTokens();
state = const AsyncValue.data(AuthState.unauthenticated());
}
}
final authProvider = AsyncNotifierProvider.autoDispose<AuthNotifier, AuthState>(
AuthNotifier.new,
);
Building a Route Guard (Auth Guard) with GoRouter #
Once your authentication state is ready, you must structure navigation route protection (Route Guard) using GoRouter. This guard functions to:
- Automatically redirect unauthenticated users to the
/loginpage if they try to open the/homehome page. - Automatically redirect authenticated users to the
/homepage if they try to access the/loginpage again.
import 'package:go_router/go_router.dart';
import 'package:flutter/material.dart';
import 'auth_notifier.dart';
import 'auth_state.dart';
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/login',
// Evaluate route protection on every navigation movement
redirect: (BuildContext context, GoRouterState navState) {
// Read the current authentication status
final authState = ref.read(authProvider).valueOrNull;
final isLoggedIn = authState is _Authenticated;
final isOnLoginScreen = navState.matchedLocation == '/login';
// Scenario 1: If not logged in and not on the login screen, force to login
if (!isLoggedIn && !isOnLoginScreen) {
return '/login';
}
// Scenario 2: If logged in and still on the login screen, redirect to home
if (isLoggedIn && isOnLoginScreen) {
return '/home';
}
return null; // Free route access if conditions are met
},
// Monitor the AuthState Stream changes to trigger automatic redirects
refreshListenable: GoRouterRefreshStream(ref.watch(authProvider.stream)),
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
GoRoute(path: '/home', builder: (_, __) => const HomePage()),
],
);
});
// Helper class to convert a regular Dart Stream into a Listenable read by GoRouter
class GoRouterRefreshStream extends ChangeNotifier {
late final StreamSubscription<dynamic> _subscription;
GoRouterRefreshStream(Stream<dynamic> stream) {
notifyListeners();
_subscription = stream.asBroadcastStream().listen((_) => notifyListeners());
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}
Third-Party Authentication Integration (Google Sign-In) #
Modern mobile apps often include Single Sign-On (SSO) integration features like signing in with a Google account (OAuth 2.0).
The Main OAuth Security Rule on Mobile: #
You must not directly use the Google access token obtained from the mobile client to access your own backend API server. The correct scenario is:
- The mobile client performs the sign-in process using the Google SDK.
- The client obtains an ID Token from Google (a JWT-formatted encrypted token containing Google-verified profile data).
- The mobile client sends that ID Token to your backend API server (
POST /auth/google-login). - Your backend API server verifies the ID Token’s authenticity with Google’s official server.
- If valid, your backend API server creates a new user account (if it doesn’t exist) and issues your own system’s internal JWT Access Token to return to the Flutter app.
Add the following dependency to your project’s pubspec.yaml file:
dependencies:
google_sign_in: ^6.2.2
Google Sign-In Implementation Code #
// services/google_auth_service.dart
import 'package:google_sign_in/google_sign_in.dart';
import '../errors/exceptions.dart';
class GoogleAuthService {
final GoogleSignIn _googleSignIn = GoogleSignIn(
scopes: ['email', 'profile'],
);
Future<GoogleSignInAuthentication?> loginWithGoogle() async {
try {
// 1. Show the Google account chooser dialog on the device
final googleAccount = await _googleSignIn.signIn();
if (googleAccount == null) return null; // The user cancelled the sign-in process
// 2. Get the authentication details (Google ID Token & Access Token)
final auth = await googleAccount.authentication;
return auth;
} catch (e) {
throw AppException('The Google sign-in process failed: $e');
}
}
Future<void> logoutGoogle() async {
await _googleSignIn.signOut();
}
}
Then you integrate the method above into your AuthNotifier:
// Inside the AuthNotifier class:
Future<void> loginWithGoogle() async {
state = const AsyncValue.data(AuthState.loading());
try {
final googleAuth = await ref.read(googleAuthServiceProvider).loginWithGoogle();
if (googleAuth == null) {
state = const AsyncValue.data(AuthState.unauthenticated());
return;
}
final repository = ref.read(authRepositoryProvider);
// Send the Google idToken to our backend to be exchanged for our own JWT token
final result = await repository.verifyGoogleToken(googleAuth.idToken!);
result.fold(
(error) => state = AsyncValue.data(AuthState.error(error.message)),
(authResult) async {
await TokenStorage.saveToken(
accessToken: authResult.accessToken,
refreshToken: authResult.refreshToken,
durationSeconds: authResult.durationSeconds,
);
state = AsyncValue.data(AuthState.authenticated(authResult.user));
},
);
} catch (e) {
state = AsyncValue.data(AuthState.error(e.toString()));
}
}
Complete Case Study: The Login Screen Widget #
Finally, let’s summarize all the integrations above into an interactive LoginPage screen complete with loading status handling, error messages, and a sign-in-with-Google button:
// presentation/screens/login_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/product_providers.dart'; // Imports authProvider
import '../providers/router_provider.dart'; // Imports routerProvider
import '../../features/auth/auth_state.dart';
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _hidePassword = true;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _submitForm() {
if (!_formKey.currentState!.validate()) return;
ref.read(authProvider.notifier).login(
_emailController.text.trim(),
_passwordController.text,
);
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState.valueOrNull is _Loading;
final errorString = authState.valueOrNull is _Error
? (authState.value as _Error).message
: null;
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(28.0),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.lock_person_rounded, size: 80, color: Colors.blue),
const SizedBox(height: 24),
Text(
'Welcome Back',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email Address',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email_outlined),
),
validator: (value) {
if (value == null || !value.contains('@')) {
return 'Please enter a valid email.';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _hidePassword,
decoration: InputDecoration(
labelText: 'Password',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline_rounded),
suffixIcon: IconButton(
icon: Icon(
_hidePassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
onPressed: () {
setState(() => _hidePassword = !_hidePassword);
},
),
),
validator: (value) {
if (value == null || value.length < 8) {
return 'Passwords must be at least 8 characters long.';
}
return null;
},
),
if (errorString != null) ...[
const SizedBox(height: 16),
Text(
errorString,
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 28),
ElevatedButton(
onPressed: isLoading ? null : _submitForm,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Sign In to Account'),
),
const SizedBox(height: 16),
const Row(
children: [
Expanded(child: Divider()),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('or'),
),
Expanded(child: Divider()),
],
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: isLoading
? null
: () => ref.read(authProvider.notifier).loginWithGoogle(),
icon: const Icon(Icons.g_mobiledata, size: 28),
label: const Text('Sign in with Google'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
],
),
),
),
),
);
}
}
Summary #
- Encrypted Token Storage must use
flutter_secure_storageto leverage the physical security APIs of Keystore (Android) and Keychain (iOS). Don’t use plainSharedPreferences.- The
TokenStorageUtility consolidates all storage management, proactive expiration tracking, and centralized token deletion.- The Dio Auth Interceptor dynamically injects the JWT access token into request headers and detects and handles automatic token refresh when receiving 401 errors.
- A Separate Dio Client must be used for the
/refreshendpoint to avoid recursive interceptor calls (infinite loop).- The Request Queue (Completer) queues all parallel requests that failed during the main token refresh process to save network resources.
- Authentication State is managed reactively using a Riverpod Notifier with neatly defined transition statuses based on the Freezed union class.
- The GoRouter Route Guard facilitates automatic navigation redirection for unauthenticated users to the login page and authenticated users to the home page declaratively.
- Google Sign-In OAuth is securely managed by sending the device-verified ID Token to your local backend API, not the Google access token.