Skip to content
ExponaBLOG
FLUTTER · DAY 062 / 100

Authentication flows in Flutter: lessons from production

Login, refresh and logout done right.

Expona Admin9 min read

Authentication flows in Flutter sit at the intersection of security and user experience. Get them wrong, and you'll either leak tokens or force users to re-login every time they close the app. In this guide, I'll walk through the patterns I've used in production across ride-hailing, travel, and SaaS platforms—showing you how to store tokens securely, stream auth state to your UI, refresh credentials silently, and handle logout across the entire app.

Store tokens securely — Secure storage, never plain preferences

The first rule of authentication is: never store sensitive tokens in SharedPreferences. SharedPreferences is plain text on Android and unencrypted on iOS. It's fine for user preferences like theme color, but tokens? No.

I use flutter_secure_storage everywhere. On Android, it uses Android Keystore. On iOS, it uses Keychain. Both are OS-level vaults designed for secrets.

dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class TokenStorage {
  static const _accessTokenKey = 'access_token';
  static const _refreshTokenKey = 'refresh_token';
  
  final _storage = const FlutterSecureStorage();

  Future<void> saveTokens({
    required String accessToken,
    required String refreshToken,
  }) async {
    await Future.wait([
      _storage.write(key: _accessTokenKey, value: accessToken),
      _storage.write(key: _refreshTokenKey, value: refreshToken),
    ]);
  }

  Future<String?> getAccessToken() async {
    return _storage.read(key: _accessTokenKey);
  }

  Future<String?> getRefreshToken() async {
    return _storage.read(key: _refreshTokenKey);
  }

  Future<void> clearTokens() async {
    await Future.wait([
      _storage.delete(key: _accessTokenKey),
      _storage.delete(key: _refreshTokenKey),
    ]);
  }
}

The real-world scenario here is a travel app I built. A user logs in at the airport, and the tokens end up in secure storage. Their phone gets stolen. With plain SharedPreferences, the thief could extract tokens and book flights as that user. With Keychain/Keystore, they can't—the OS makes decryption impossible without the device unlock PIN. That's the difference.

One gotcha: on iOS, secure storage can be slow on first launch if the device hasn't decrypted the Keychain yet. Cache tokens in memory after the first read to avoid janky navigation.

Auth state stream — The router listens and redirects automatically

Once tokens are stored safely, your UI needs to know whether the user is logged in. This is where a StateNotifier or StreamController becomes essential. I'll show both patterns; the principle is the same.

Create an auth provider that exposes the current auth state as a stream. Your router listens to that stream and redirects automatically. No manual navigation logic scattered across screens.

dart
enum AuthStatus { initial, authenticated, unauthenticated }

class AuthState {
  final AuthStatus status;
  final User? user;
  
  AuthState({
    required this.status,
    this.user,
  });
}

class AuthNotifier extends StateNotifier<AuthState> {
  final TokenStorage _tokenStorage;
  final ApiClient _apiClient;

  AuthNotifier(this._tokenStorage, this._apiClient)
      : super(AuthState(status: AuthStatus.initial));

  Future<void> initialize() async {
    final accessToken = await _tokenStorage.getAccessToken();
    if (accessToken != null) {
      try {
        final user = await _apiClient.getCurrentUser();
        state = AuthState(status: AuthStatus.authenticated, user: user);
      } catch (e) {
        // Token is stale or revoked
        state = AuthState(status: AuthStatus.unauthenticated);
      }
    } else {
      state = AuthState(status: AuthStatus.unauthenticated);
    }
  }

  Future<void> login({
    required String email,
    required String password,
  }) async {
    state = AuthState(status: AuthStatus.initial);
    try {
      final response = await _apiClient.login(email: email, password: password);
      await _tokenStorage.saveTokens(
        accessToken: response.accessToken,
        refreshToken: response.refreshToken,
      );
      final user = await _apiClient.getCurrentUser();
      state = AuthState(status: AuthStatus.authenticated, user: user);
    } catch (e) {
      state = AuthState(status: AuthStatus.unauthenticated);
      rethrow;
    }
  }
}

final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
  return AuthNotifier(
    ref.watch(tokenStorageProvider),
    ref.watch(apiClientProvider),
  );
});

Then, in your main router (whether using go_router, auto_route, or vanilla Navigation 2), listen to the auth state:

dart
final routerProvider = Provider<GoRouter>((ref) {
  final authState = ref.watch(authProvider);
  
  return GoRouter(
    initialLocation: '/',
    redirect: (context, state) {
      if (authState.status == AuthStatus.initial) {
        return '/splash';
      }
      if (authState.status == AuthStatus.unauthenticated) {
        return '/login';
      }
      // Already authenticated
      if (state.location == '/login') {
        return '/home';
      }
      return null;
    },
    routes: [
      GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
      GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
      GoRoute(path: '/splash', builder: (_, __) => const SplashScreen()),
    ],
  );
});

In production, I use this pattern for a CRM platform. When a user logs out on another device (say, their desktop), the backend invalidates their tokens. The next time their Flutter app tries to refresh silently (covered in the next section), it gets a 401. The auth notifier flips to unauthenticated, the router catches it, and they're back at the login screen instantly. No stale cached screens lingering in memory.

Silent refresh — Refresh tokens in an interceptor

Access tokens expire—typically in 15 minutes. Refresh tokens live longer (days or weeks). Rather than bouncing users to the login screen every 15 minutes, refresh silently before the access token dies.

The cleanest way is an HTTP interceptor. I use dio for this; here's the pattern:

dart
class AuthInterceptor extends Interceptor {
  final TokenStorage _tokenStorage;
  final Dio _dio;
  final AuthNotifier _authNotifier;

  AuthInterceptor({
    required TokenStorage tokenStorage,
    required Dio dio,
    required AuthNotifier authNotifier,
  })  : _tokenStorage = tokenStorage,
        _dio = dio,
        _authNotifier = authNotifier;

  @override
  Future<void> onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final accessToken = await _tokenStorage.getAccessToken();
    if (accessToken != null) {
      options.headers['Authorization'] = 'Bearer $accessToken';
    }
    handler.next(options);
  }

  @override
  Future<void> onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    if (err.response?.statusCode == 401) {
      // Token expired, try to refresh
      final refreshToken = await _tokenStorage.getRefreshToken();
      if (refreshToken == null) {
        _authNotifier.logout();
        return handler.next(err);
      }

      try {
        final newTokens = await _refreshAccessToken(refreshToken);
        await _tokenStorage.saveTokens(
          accessToken: newTokens.accessToken,
          refreshToken: newTokens.refreshToken,
        );

        // Retry original request with new token
        final options = err.requestOptions;
        options.headers['Authorization'] = 'Bearer ${newTokens.accessToken}';
        final response = await _dio.request<dynamic>(
          options.path,
          options: options,
        );
        return handler.resolve(response);
      } catch (e) {
        _authNotifier.logout();
        return handler.next(err);
      }
    }

    handler.next(err);
  }

  Future<TokenResponse> _refreshAccessToken(String refreshToken) async {
    final response = await _dio.post<Map<String, dynamic>>(
      '/auth/refresh',
      data: {'refresh_token': refreshToken},
      options: Options(
        extra: {'no_intercept': true}, // Prevent infinite recursion
      ),
    );
    return TokenResponse.fromJson(response.data!);
  }
}

This interceptor fires on every request. If the response is 401, it grabs the refresh token, exchanges it for a new access token, and retries the original request. The no_intercept flag prevents the refresh request itself from being intercepted again.

In a ride-hailing app I worked on, a user booked a ride at 2:59 PM, and the request took 2 minutes to reach the backend (poor connectivity). Their access token, which expired at 3:00 PM, was stale by the time the server processed the booking. The interceptor caught the 401, refreshed silently, and retried. The user never saw a login screen.

One edge case: if two requests fail with 401 simultaneously, both will try to refresh. Use a mutex to ensure only one refresh happens at a time:

dart
class AuthInterceptor extends Interceptor {
  static final _refreshMutex = Mutex();

  @override
  Future<void> onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    if (err.response?.statusCode == 401) {
      await _refreshMutex.protect(() async {
        // Refresh logic here
      });
    }
    handler.next(err);
  }
}

Logout everywhere — Clear tokens, caches and state

Logout isn't just deleting a token. It's a cascade: clear secure storage, flush any cached API responses, reset the auth state, and redirect to login.

dart
class AuthNotifier extends StateNotifier<AuthState> {
  final TokenStorage _tokenStorage;
  final ApiClient _apiClient;
  final ApiCache _cache; // Custom cache layer

  Future<void> logout() async {
    // 1. Clear tokens from secure storage
    await _tokenStorage.clearTokens();

    // 2. Clear all cached API responses
    _cache.clearAll();

    // 3. Tell the API client to invalidate its session (optional, best-effort)
    try {
      await _apiClient.logout();
    } catch (e) {
      // Network error, but we've already cleared local state
    }

    // 4. Reset auth state—triggers redirect to login
    state = AuthState(status: AuthStatus.unauthenticated);
  }
}

Why clear caches? Imagine a user logs out, and then another user logs into the same device. If you don't flush the cache, the second user's app might show the first user's cached travel history or order details. That's a serious privacy leak.

In a SaaS platform I worked on, we also called an API endpoint POST /auth/logout to notify the backend. The backend then revoked the refresh token, making it impossible for anyone else to refresh even if they stole it. This is belt-and-suspenders security.

Common mistakes

  • Storing tokens in SharedPreferences: I've seen this in production. Don't do it. Use flutter_secure_storage.
  • Caching user data forever: If a user's permissions change on the backend, their app should reflect it. Implement a reasonable cache TTL (time-to-live).
  • Forgetting to clear state on logout: Leaving the auth state as "authenticated" after clearing tokens means the router won't redirect, and users can still navigate to protected screens where all requests fail.
  • Not handling refresh token expiry: If the refresh token itself has expired, retrying refresh will fail forever. Detect this and log the user out immediately.
  • Hardcoding token expiry times: The backend controls token lifetime. Don't assume access tokens are always 15 minutes. Read the expiry from the JWT's exp claim, or let the server tell you.

Key takeaways

  • Always use flutter_secure_storage for tokens, never SharedPreferences.
  • Stream your auth state from a StateNotifier or similar, and let your router listen and redirect automatically.
  • Intercept 401 responses and refresh tokens silently before retrying the request.
  • Logout is a multi-step operation: clear tokens, flush caches, reset state, and optionally notify the backend.
  • Test logout edge cases: refresh token expiry, network failures during refresh, and simultaneous requests after logout.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles