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
- What Are Access Tokens? — An access token is a short string (usually a JWT) that proves you are logged in. You send it with every API request in the Authorization header. Access tokens expire quickly (15 minutes to 1 hour) for security -- if someone steals it, they can only use it briefly.
- What Are Refresh Tokens? — A refresh token is a long-lived token (days or weeks) used to get a NEW access token when the old one expires. You never send it with regular API requests -- only to the refresh endpoint. If the refresh token expires too, the user must log in again with their username and password.
- TokenStorage in team_mvp_kit — team_mvp_kit has a TokenStorage class that saves tokens in Hive's encrypted box. It stores the access token, refresh token, and expiry timestamp. It provides methods to save, retrieve, check expiry, and clear tokens. It is registered as a @singleton via injectable.
- Saving Tokens After Login — When the user logs in, the server returns both tokens and the expiry time. Save all three to the encrypted storage. Calculate the absolute expiry timestamp from the relative expiresIn seconds. team_mvp_kit does this in the repository right after a successful login API call.
- Auto-Refresh with Interceptor — The AuthInterceptor in team_mvp_kit checks every 401 response. If the access token expired, it calls the refresh endpoint with the refresh token, saves the new tokens, and retries the original request. The user never notices -- the refresh happens invisibly behind the scenes.
- Handling Concurrent Refresh Requests — When multiple API calls fail with 401 at the same time, you must not send multiple refresh requests. Use a flag (_isRefreshing) and a Completer to queue concurrent requests. The first 401 triggers the refresh; all others wait for it to complete, then retry with the new token.
- Logout and Token Cleanup — When the user logs out (or when token refresh fails), clear all stored tokens, reset in-memory state, and navigate to the login screen. team_mvp_kit calls tokenStorage.clearTokens() and then uses go_router to redirect to the login route.
- Checking Auth State on App Start — When the app launches, check if the user has a valid token. If yes, go to the home screen. If the token is expired, try to refresh it. If no tokens at all, go to login. team_mvp_kit uses a GoRouter redirect that checks TokenStorage on every navigation.
- Proactive Token Refresh — Instead of waiting for a 401 error, you can proactively check if the token is about to expire before making a request. If the token expires within the next 60 seconds, refresh it preemptively. This avoids the 401-retry cycle and gives a smoother experience.
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. Import injectable for dependency injection
- 2.
- 3. Comment: TokenStorage with encrypted persistence
- 4. @singleton annotation for single instance across the app
- 5. TokenStorage class declaration
- 6. Private field for LocalDatabaseService (injected)
- 7. Constant key string for access token storage
- 8. Constant key string for refresh token storage
- 9. Constant key string for expiry timestamp storage
- 10.
- 11. Constructor receives the database service via DI
- 12.
- 13. Comment: Read tokens from secure storage
- 14. Getter reads access token from encrypted box
- 15. Getter reads refresh token from encrypted box
- 16. isLoggedIn checks if an access token exists
- 17.
- 18. Comment: Check if token has expired
- 19. isTokenExpired getter
- 20. Read the stored expiry timestamp
- 21. If no expiry stored, consider it expired
- 22. Compare current time to stored expiry
- 23. Closing isTokenExpired
- 24.
- 25. Helper to check if token expires within N seconds
- 26. Read the expiry timestamp
- 27. If no expiry, it is expiring soon
- 28. Calculate the buffer window in milliseconds
- 29. Return true if current time is within the buffer of expiry
- 30. Closing isTokenExpiringSoon
- 31.
- 32. Comment: Save tokens after login or refresh
- 33. saveTokens takes a TokensDto with all token data
- 34. Store access token in encrypted box
- 35. Store refresh token in encrypted box
- 36. Calculate absolute expiry from relative expiresIn seconds
- 37. Store the expiry timestamp in regular box
- 38. Closing saveTokens
- 39.
- 40. Comment: Clear tokens on logout
- 41. clearTokens removes all auth data
- 42. Delete access token from encrypted box
- 43. Delete refresh token from encrypted box
- 44. Delete expiry timestamp
- 45. Closing clearTokens
- 46.
- 47. Comment: Attempt to refresh the token
- 48. refreshTokens method returns success/failure boolean
- 49. Get the current refresh token
- 50. If no refresh token exists, cannot refresh
- 51. Try block for the refresh API call
- 52. Placeholder for actual refresh endpoint call
- 53. Return false as default (replace with real implementation)
- 54. Catch any errors
- 55. Clear all tokens on refresh failure
- 56. Return false on failure
- 57. Closing refreshTokens and TokenStorage
- 58.
- 59. Comment: AuthInterceptor handles token attachment and refresh
- 60. AuthInterceptor extends Dio's Interceptor
- 61. Private TokenStorage field
- 62. Private Dio field for retrying requests
- 63. Flag to prevent concurrent refresh attempts
- 64.
- 65. Constructor receives both dependencies
- 66.
- 67. Override onRequest to attach token to every request
- 68. Method signature
- 69. Get the current access token
- 70. If token exists, add it to the Authorization header
- 71. Set the Bearer token value
- 72. Closing the if block
- 73. Continue to next interceptor
- 74. Closing onRequest
- 75.
- 76. Override onError to handle 401 and refresh
- 77. Method signature
- 78. Check for 401 status AND not already refreshing
- 79. Set the refreshing flag to true
- 80. Try to refresh the token
- 81. If refresh succeeded, update the request header
- 82. Set the new token on the retry request
- 83. Retry the original request
- 84. Return the successful retry response
- 85. Finally block always resets the refreshing flag
- 86. Set flag back to false
- 87. Closing the 401 handling block
- 88. Pass non-401 errors through
- 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
- Flutter Secure Storage (pub.dev)
- JWT Introduction (Auth0)
- Dio Interceptors (pub.dev)