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:

  1. 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.
  2. 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 Data

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

  1. Automatically injecting the Authorization: Bearer *** header into every outgoing request (onRequest).
  2. Intercepting 401 Unauthorized errors 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 Dio instance 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/refresh route.
  • 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:

  1. Automatically redirect unauthenticated users to the /login page if they try to open the /home home page.
  2. Automatically redirect authenticated users to the /home page if they try to access the /login page 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:

  1. The mobile client performs the sign-in process using the Google SDK.
  2. The client obtains an ID Token from Google (a JWT-formatted encrypted token containing Google-verified profile data).
  3. The mobile client sends that ID Token to your backend API server (POST /auth/google-login).
  4. Your backend API server verifies the ID Token’s authenticity with Google’s official server.
  5. 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_storage to leverage the physical security APIs of Keystore (Android) and Keychain (iOS). Don’t use plain SharedPreferences.
  • The TokenStorage Utility 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 /refresh endpoint 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.

← Previous: Error Handling   Next: GraphQL →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact