Lesson 46 of 51 advanced

Token & Auth Storage

Keeping Users Logged In Securely

Open interactive version (quiz + challenge)

Real-world analogy

Imagine a theme park where you buy a day pass (access token) at the gate. The pass expires every hour, but you also get a special renewal card (refresh token). When your pass expires, you show the renewal card to get a fresh pass without buying a new ticket. If you lose the renewal card too, you have to go back to the ticket booth (login again). That is exactly how token-based authentication works!

What is it?

Token-based authentication uses short-lived access tokens for API requests and long-lived refresh tokens to renew them. team_mvp_kit's TokenStorage persists both tokens in Hive's encrypted box. The AuthInterceptor automatically attaches tokens to requests and transparently refreshes expired ones, keeping users logged in without manual intervention.

Real-world relevance

Every professional app with user accounts uses this pattern. When you stay logged in to Instagram for weeks, that is refresh tokens at work. team_mvp_kit implements the complete flow: login saves tokens, the interceptor auto-attaches and auto-refreshes them, concurrent 401s are queued, and logout clears everything securely.

Key points

Code example

import 'package:injectable/injectable.dart';

// 1. TokenStorage - encrypted persistence (team_mvp_kit)
@singleton
class TokenStorage {
  final LocalDatabaseService _db;
  static const _accessKey = 'access_token';
  static const _refreshKey = 'refresh_token';
  static const _expiryKey = 'token_expiry';

  TokenStorage(this._db);

  // Read tokens
  String? get accessToken => _db.getSecure(_accessKey);
  String? get refreshToken => _db.getSecure(_refreshKey);
  bool get isLoggedIn => accessToken != null;

  // Check expiry
  bool get isTokenExpired {
    final expiry = _db.get<int>(_expiryKey);
    if (expiry == null) return true;
    return DateTime.now().millisecondsSinceEpoch > expiry;
  }

  bool isTokenExpiringSoon({int seconds = 60}) {
    final expiry = _db.get<int>(_expiryKey);
    if (expiry == null) return true;
    final buffer = seconds * 1000;
    return DateTime.now().millisecondsSinceEpoch > (expiry - buffer);
  }

  // Save tokens after login or refresh
  Future<void> saveTokens(TokensDto tokens) async {
    await _db.putSecure(_accessKey, tokens.accessToken);
    await _db.putSecure(_refreshKey, tokens.refreshToken);
    final expiryMs = DateTime.now().millisecondsSinceEpoch +
        (tokens.expiresIn * 1000);
    await _db.put(_expiryKey, expiryMs);
  }

  // Clear everything on logout
  Future<void> clearTokens() async {
    await _db.deleteSecure(_accessKey);
    await _db.deleteSecure(_refreshKey);
    await _db.delete(_expiryKey);
  }

  // Attempt token refresh via API
  Future<bool> refreshTokens() async {
    final refresh = refreshToken;
    if (refresh == null) return false;
    try {
      // This would call the refresh endpoint
      // For now, return false to indicate failure
      return false;
    } catch (_) {
      await clearTokens();
      return false;
    }
  }
}

// 2. Auth Interceptor with concurrent refresh handling
class AuthInterceptor extends Interceptor {
  final TokenStorage _tokenStorage;
  final Dio _dio;
  bool _isRefreshing = false;

  AuthInterceptor(this._tokenStorage, this._dio);

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

  @override
  void onError(
    DioException error,
    ErrorInterceptorHandler handler,
  ) async {
    if (error.response?.statusCode == 401 && !_isRefreshing) {
      _isRefreshing = true;
      try {
        final refreshed = await _tokenStorage.refreshTokens();
        if (refreshed) {
          error.requestOptions.headers['Authorization'] =
              'Bearer ${_tokenStorage.accessToken}';
          final response = await _dio.fetch(error.requestOptions);
          return handler.resolve(response);
        }
      } finally {
        _isRefreshing = false;
      }
    }
    handler.next(error);
  }
}

Line-by-line walkthrough

  1. 1. Import injectable for dependency injection
  2. 2.
  3. 3. Comment: TokenStorage with encrypted persistence
  4. 4. @singleton annotation for single instance across the app
  5. 5. TokenStorage class declaration
  6. 6. Private field for LocalDatabaseService (injected)
  7. 7. Constant key string for access token storage
  8. 8. Constant key string for refresh token storage
  9. 9. Constant key string for expiry timestamp storage
  10. 10.
  11. 11. Constructor receives the database service via DI
  12. 12.
  13. 13. Comment: Read tokens from secure storage
  14. 14. Getter reads access token from encrypted box
  15. 15. Getter reads refresh token from encrypted box
  16. 16. isLoggedIn checks if an access token exists
  17. 17.
  18. 18. Comment: Check if token has expired
  19. 19. isTokenExpired getter
  20. 20. Read the stored expiry timestamp
  21. 21. If no expiry stored, consider it expired
  22. 22. Compare current time to stored expiry
  23. 23. Closing isTokenExpired
  24. 24.
  25. 25. Helper to check if token expires within N seconds
  26. 26. Read the expiry timestamp
  27. 27. If no expiry, it is expiring soon
  28. 28. Calculate the buffer window in milliseconds
  29. 29. Return true if current time is within the buffer of expiry
  30. 30. Closing isTokenExpiringSoon
  31. 31.
  32. 32. Comment: Save tokens after login or refresh
  33. 33. saveTokens takes a TokensDto with all token data
  34. 34. Store access token in encrypted box
  35. 35. Store refresh token in encrypted box
  36. 36. Calculate absolute expiry from relative expiresIn seconds
  37. 37. Store the expiry timestamp in regular box
  38. 38. Closing saveTokens
  39. 39.
  40. 40. Comment: Clear tokens on logout
  41. 41. clearTokens removes all auth data
  42. 42. Delete access token from encrypted box
  43. 43. Delete refresh token from encrypted box
  44. 44. Delete expiry timestamp
  45. 45. Closing clearTokens
  46. 46.
  47. 47. Comment: Attempt to refresh the token
  48. 48. refreshTokens method returns success/failure boolean
  49. 49. Get the current refresh token
  50. 50. If no refresh token exists, cannot refresh
  51. 51. Try block for the refresh API call
  52. 52. Placeholder for actual refresh endpoint call
  53. 53. Return false as default (replace with real implementation)
  54. 54. Catch any errors
  55. 55. Clear all tokens on refresh failure
  56. 56. Return false on failure
  57. 57. Closing refreshTokens and TokenStorage
  58. 58.
  59. 59. Comment: AuthInterceptor handles token attachment and refresh
  60. 60. AuthInterceptor extends Dio's Interceptor
  61. 61. Private TokenStorage field
  62. 62. Private Dio field for retrying requests
  63. 63. Flag to prevent concurrent refresh attempts
  64. 64.
  65. 65. Constructor receives both dependencies
  66. 66.
  67. 67. Override onRequest to attach token to every request
  68. 68. Method signature
  69. 69. Get the current access token
  70. 70. If token exists, add it to the Authorization header
  71. 71. Set the Bearer token value
  72. 72. Closing the if block
  73. 73. Continue to next interceptor
  74. 74. Closing onRequest
  75. 75.
  76. 76. Override onError to handle 401 and refresh
  77. 77. Method signature
  78. 78. Check for 401 status AND not already refreshing
  79. 79. Set the refreshing flag to true
  80. 80. Try to refresh the token
  81. 81. If refresh succeeded, update the request header
  82. 82. Set the new token on the retry request
  83. 83. Retry the original request
  84. 84. Return the successful retry response
  85. 85. Finally block always resets the refreshing flag
  86. 86. Set flag back to false
  87. 87. Closing the 401 handling block
  88. 88. Pass non-401 errors through
  89. 89. Closing onError and AuthInterceptor

Spot the bug

class TokenStorage {
  String? _accessToken;
  String? _refreshToken;

  void saveTokens(String access, String refresh) {
    _accessToken = access;
    _refreshToken = refresh;
  }

  String? get accessToken => _accessToken;
  bool get isLoggedIn => _accessToken != null;
}
Need a hint?
Think about what happens when the user closes and reopens the app...
Show answer
The tokens are stored only in memory (instance variables). When the app is closed and reopened, both tokens are lost and the user appears logged out. Fix: persist tokens to an encrypted Hive box or flutter_secure_storage. Also, saveTokens should be async/Future<void> since storage operations are asynchronous.

Explain like I'm 5

Imagine you go to a water park. At the entrance, they give you a wristband (access token) that lets you ride any slide for 1 hour. They also give you a special card (refresh token) that lasts all day. When your wristband beeps after 1 hour, you show your card at the info desk and they give you a fresh wristband -- you do not have to buy a new ticket! But if you lose both the wristband AND the card, you have to go back to the entrance and buy a new ticket (login again).

Fun fact

JSON Web Tokens (JWTs) are not encrypted -- they are just base64 encoded! Anyone can decode and read the payload. Never put sensitive data like passwords inside a JWT. The signature only proves the token was issued by your server, it does not hide the contents. You can paste any JWT into jwt.io and read it!

Hands-on challenge

Build a complete TokenStorage class with: saveTokens(), clearTokens(), isLoggedIn, isTokenExpired, and accessToken/refreshToken getters. Use Hive's encrypted box. Then create a GoRouter redirect that checks isLoggedIn and redirects to /login or /home appropriately.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter & Dart