Skip to content
FlutterLearn

Intermediate · Lesson 8 · 14 min read

Authentication and Session Management

Build a complete sign-in flow: token storage, automatic refresh, an auth state that drives the UI, and secure sign-out.

Updated August 1, 2026

What you will learn

  • Model auth state so the UI reacts to it automatically
  • Store tokens securely on each platform
  • Refresh expired tokens without the user noticing
  • Handle sign-out, session expiry and biometric unlock

Almost every app needs sign-in, and almost every first attempt has the same three bugs: the token is stored insecurely, the app does not know what to do when it expires, and auth state is checked in individual screens rather than centrally.

Model the state first

lib/auth/auth_state.dart
sealed class AuthState {
  const AuthState();
}

/// Reading stored credentials on launch — show a splash, not a login form
final class AuthUnknown extends AuthState {
  const AuthUnknown();
}

final class Authenticated extends AuthState {
  const Authenticated(this.user);
  final User user;
}

final class Unauthenticated extends AuthState {
  const Unauthenticated({this.reason});
  /// e.g. 'Your session expired' — worth telling the user
  final String? reason;
}

Storing tokens securely

lib/auth/token_store.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class TokenStore {
  static const _storage = FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
  );

  static const _accessKey = 'access_token';
  static const _refreshKey = 'refresh_token';

  Future<void> save({required String access, required String refresh}) async {
    await Future.wait([
      _storage.write(key: _accessKey, value: access),
      _storage.write(key: _refreshKey, value: refresh),
    ]);
  }

  Future<String?> get accessToken => _storage.read(key: _accessKey);
  Future<String?> get refreshToken => _storage.read(key: _refreshKey);

  Future<void> clear() => _storage.deleteAll();
}

flutter_secure_storage uses the iOS Keychain and Android's EncryptedSharedPreferences backed by the Keystore. shared_preferences is plain text on disk — never put a token in it.

The auth controller

Dart
class AuthController extends ChangeNotifier {
  AuthController({required this.api, required this.tokens});

  final AuthApi api;
  final TokenStore tokens;

  AuthState _state = const AuthUnknown();
  AuthState get state => _state;
  bool get isLoggedIn => _state is Authenticated;

  void _set(AuthState next) {
    _state = next;
    notifyListeners();
  }

  /// Called once at startup, before the first screen is shown.
  Future<void> restore() async {
    final token = await tokens.accessToken;
    if (token == null) {
      _set(const Unauthenticated());
      return;
    }

    try {
      final user = await api.me();
      _set(Authenticated(user));
    } on ApiException catch (e) {
      if (e.statusCode == 401) {
        await tokens.clear();
        _set(const Unauthenticated(reason: 'Your session expired'));
      } else {
        // Offline: trust the stored token rather than logging the user out
        _set(Authenticated(await _cachedUser()));
      }
    }
  }

  Future<void> signIn(String email, String password) async {
    final result = await api.signIn(email: email, password: password);
    await tokens.save(access: result.accessToken, refresh: result.refreshToken);
    _set(Authenticated(result.user));
  }

  Future<void> signOut() async {
    // Revoke server-side first, but never let a failure trap the user
    try {
      await api.signOut();
    } catch (_) {}
    await tokens.clear();
    _set(const Unauthenticated());
  }
}

Automatic token refresh

Dart
class AuthInterceptor extends Interceptor {
  AuthInterceptor(this.tokens, this.dio, this.onSessionExpired);

  final TokenStore tokens;
  final Dio dio;
  final VoidCallback onSessionExpired;

  // Ensures ten parallel 401s trigger one refresh, not ten
  Future<String?>? _refreshFuture;

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

  @override
  Future<void> onError(DioException error, handler) async {
    if (error.response?.statusCode != 401) {
      return handler.next(error);
    }

    final newToken = await (_refreshFuture ??= _refresh());
    _refreshFuture = null;

    if (newToken == null) {
      onSessionExpired();
      return handler.next(error);
    }

    // Replay the original request with the fresh token
    final request = error.requestOptions;
    request.headers['Authorization'] = 'Bearer $newToken';
    try {
      handler.resolve(await dio.fetch(request));
    } on DioException catch (e) {
      handler.next(e);
    }
  }

  Future<String?> _refresh() async {
    final refresh = await tokens.refreshToken;
    if (refresh == null) return null;
    try {
      final result = await AuthApi.rawRefresh(refresh);
      await tokens.save(
        access: result.accessToken,
        refresh: result.refreshToken,
      );
      return result.accessToken;
    } catch (_) {
      await tokens.clear();
      return null;
    }
  }
}

The shared _refreshFuture is the important detail. When a screen fires five requests at once and all five come back 401, you want one refresh call — not five, four of which will fail because the refresh token was already rotated.

Driving the UI from auth state

Dart
// With go_router, auth is a redirect, not a per-screen check
final router = GoRouter(
  refreshListenable: authController,
  redirect: (context, state) {
    final auth = authController.state;

    // Still restoring — hold on the splash route
    if (auth is AuthUnknown) return '/splash';

    final loggedIn = auth is Authenticated;
    final atAuthScreen = state.matchedLocation == '/login' ||
        state.matchedLocation == '/splash';

    if (!loggedIn && !atAuthScreen) return '/login';
    if (loggedIn && atAuthScreen) return '/';
    return null;
  },
  routes: [/* ... */],
);

Biometric unlock

Dart
import 'package:local_auth/local_auth.dart';

Future<bool> unlockWithBiometrics() async {
  final auth = LocalAuthentication();

  if (!await auth.canCheckBiometrics || !await auth.isDeviceSupported()) {
    return false;   // fall back to password
  }

  try {
    return await auth.authenticate(
      localizedReason: 'Unlock FlutterLearn',
      options: const AuthenticationOptions(
        stickyAuth: true,      // survives backgrounding mid-prompt
        biometricOnly: false,  // allow device PIN as a fallback
      ),
    );
  } on PlatformException {
    return false;
  }
}

Security checklist

  • Tokens in secure storage only — never shared_preferences, never a plain file.
  • Short-lived access tokens with a refresh token; rotate the refresh token on every use.
  • Clear all credentials and any cached user data on sign-out, including local databases.
  • Never log tokens, even in debug — logs are readable on a connected device.
  • No API secrets in the app binary; anything privileged happens on your server.
  • Set autofillHints on the login fields so password managers work, and call TextInput.finishAutofillContext() after a successful sign-in so the OS offers to save.

Key takeaways

  • Model auth as a sealed state including an unknown phase, or the login screen flashes on every launch.
  • Store tokens in flutter_secure_storage; only clear them on a genuine 401.
  • Share one refresh future so concurrent 401s do not trigger concurrent refreshes.
  • Guard routes centrally with a redirect rather than checking auth per screen.

Practice

A complete auth flow

Build sign-in, restore-on-launch with a splash state, an interceptor that refreshes expired tokens and replays the request, and sign-out that clears both tokens and the local cache. Then test it: expire the token server-side and confirm the app recovers without the user noticing.

Show hints
  • Fire several requests at once with an expired token to verify only one refresh happens.
  • Test airplane mode on launch — it should keep the user signed in, not eject them.