secure_token_manager
A production-grade, platform-aware authentication token lifecycle manager for Flutter applications with single-flight concurrent refresh protection, race condition immunity, and zero coupling to any networking client.
1. Problem Statement
Flutter applications communicating with authenticated REST/GraphQL APIs commonly face the following challenges:
- Insecure storage: Storing credentials in plaintext
SharedPreferencesorlocalStorage. - Duplicated refresh logic: Re-implementing token refresh in every HTTP interceptor or service.
- Thundering herd (concurrent API calls): When 5 or 10 parallel API requests fail with 401s simultaneously, applications mistakenly launch 5 or 10 concurrent refresh calls to the backend, causing rate-limiting, invalidated sessions, or server errors.
- Race conditions during logout: If a refresh request is in flight when the user taps "Logout", the late refresh response often arrives and erroneously writes the new tokens back to storage, resurrecting a logged-out session.
- Loss of state on restart: Inconsistent hydration between secure hardware storage and memory.
- Tight HTTP client coupling: Forcing the authentication state to be coupled directly to Dio, Chopper, or package:http.
- Platform inconsistencies: Handling Android Keystore, iOS/macOS Keychain, Windows DPAPI, and Linux Secret Service manually.
- Untestable expiration logic: Hardcoding
DateTime.now()making it difficult to write deterministic unit tests.
secure_token_manager solves these problems through clean architecture, single-flight concurrency coalescing, session epoch guards, and platform-native secure storage.
2. Features
- Single-Flight Concurrency Protection: Deduplicates multiple simultaneous refresh requests into a single network execution, sharing the result across all waiting callers.
- Race Condition Immunity: Uses a monotonically increasing session epoch to guarantee that stale in-flight refresh responses can never revive a logged-out or invalidated session.
- Hardware-Backed Platform Storage: Uses AES-GCM via Android KeyStore, Apple Keychain with
first_unlockaccessibility, Windows DPAPI Credential Store, and Linuxlibsecret. - Zero Plaintext Fallback: Never falls back to insecure storage. Explicitly rejects Flutter Web in v1 to avoid false security promises with
localStorage. - Clock-Skew Leeway: Proactively refreshes tokens slightly ahead of actual expiration (default: 60s), preventing in-flight API requests from failing at the boundary.
- Secret Redaction:
TokenData.toString(), error messages, and log records strictly redact tokens and secrets. - Zero Network Coupling: Works seamlessly with Dio,
package:http, GraphQL, gRPC, or custom WebSocket layers. - Deterministic Testing: Fully testable using injected
ClockandMemoryTokenStorage. - Observable Lifecycle: Exposes a reactive
Stream<AuthState>and token-safeStream<AuthEvent>.
3. Supported Platforms Matrix
| Platform | Supported in v1 | Underlying Mechanism |
|---|---|---|
| Android | ✅ Yes | Android KeyStore with AES-GCM data encryption and RSA-OAEP key wrapping |
| iOS | ✅ Yes | Apple Keychain Services (KeychainAccessibility.first_unlock) |
| macOS | ✅ Yes | Apple Keychain Services (KeychainAccessibility.first_unlock) |
| Windows | ✅ Yes | Windows Data Protection API (DPAPI) via Credential Manager |
| Linux | ✅ Yes | Secret Service API (libsecret) |
| Web | ❌ No | Web browsers lack hardware-backed credential vaults. Using localStorage is vulnerable to XSS and strictly disallowed in v1. |
4. Installation
Add secure_token_manager to your pubspec.yaml:
dependencies:
secure_token_manager: ^0.1.0
Then run:
flutter pub get
5. Quick Start
import 'package:secure_token_manager/secure_token_manager.dart';
void main() async {
// 1. Instantiate TokenManager with your backend's refresh callback
final tokenManager = TokenManager(
storage: SecureTokenStorage(), // Platform secure storage
refreshToken: (String currentRefreshToken) async {
// Execute your application-specific HTTP call
final response = await myAuthApi.refreshToken(currentRefreshToken);
return TokenData(
accessToken: response.accessToken,
refreshToken: response.refreshToken, // Supports token rotation
expiresAt: response.expiresAt,
);
},
);
// 2. Initialize from secure storage on app launch
await tokenManager.initialize();
// 3. Set tokens when the user logs in
await tokenManager.setTokens(
TokenData(
accessToken: 'initial_access_token',
refreshToken: 'initial_refresh_token',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
),
);
// 4. Retrieve a valid access token for outgoing API calls
// (Automatically refreshes single-flight if expired or expiring soon)
final accessToken = await tokenManager.getValidAccessToken();
// 5. Observe authentication state changes
tokenManager.authState.listen((AuthState state) {
if (state.isUnauthenticated) {
navigateToLoginScreen();
}
});
// 6. Log out
await tokenManager.logout();
}
6. Architecture & Concurrency Model
Single-Flight Refresh Coalescing
When multiple asynchronous requests require an access token at the same time:
Request 1 (expired) ──┐
Request 2 (expired) ──┼──> [ Single-Flight Refresh Coordinator ] ──> ONE Backend Refresh Call
Request 3 (expired) ──┘ │
▼
New Token Pair Saved
│
┌────────────────┼────────────────┐
▼ ▼ ▼
Request 1 Request 2 Request 3
All callers await the exact same internal Completer<TokenData>. If the refresh succeeds, all callers receive the new token. If it fails, all callers receive the typed exception.
Logout Race Condition Guard
Time 0ms: Token expired -> Refresh initiated (Epoch 1)
Time 150ms: User clicks "Logout" -> Storage cleared, Memory wiped, Epoch incremented to 2
Time 500ms: Refresh HTTP response arrives with new tokens
Result: Coordinator checks epoch (1 != 2) -> Discards new tokens immediately!
Storage and Memory remain empty. The session remains logged out.
7. Dio Integration Recipe
The core package does not depend on Dio. You can easily integrate it into your Dio client using an interceptor:
import 'package:dio/dio.dart';
import 'package:secure_token_manager/secure_token_manager.dart';
class AuthInterceptor extends QueuedInterceptor {
AuthInterceptor(this._tokenManager, this._dio);
final TokenManager _tokenManager;
final Dio _dio;
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
try {
final token = await _tokenManager.getValidAccessToken();
options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
} on TokenException catch (e) {
handler.reject(
DioException(
requestOptions: options,
error: e,
type: DioExceptionType.cancel,
),
);
}
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
// Handle unexpected 401 (e.g. token revoked prematurely on server)
if (err.response?.statusCode == 401 && err.requestOptions.extra['retry'] != true) {
try {
// Trigger single-flight refresh
await _tokenManager.refreshTokens();
// Retry the original request once
final retryOptions = err.requestOptions;
retryOptions.extra['retry'] = true;
final token = await _tokenManager.getValidAccessToken();
retryOptions.headers['Authorization'] = 'Bearer $token';
final response = await _dio.fetch(retryOptions);
return handler.resolve(response);
} catch (_) {
// If refresh fails or is rejected, let original error propagate
}
}
handler.next(err);
}
}
8. HTTP Client (package:http) Integration Recipe
import 'package:http/http.dart' as http;
import 'package:secure_token_manager/secure_token_manager.dart';
class AuthenticatedHttpClient extends http.BaseClient {
AuthenticatedHttpClient(this._inner, this._tokenManager);
final http.Client _inner;
final TokenManager _tokenManager;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final token = await _tokenManager.getValidAccessToken();
request.headers['Authorization'] = 'Bearer $token';
return _inner.send(request);
}
}
9. Error Taxonomy
All exceptions extend the sealed class TokenException:
TokenStorageException: Failure in underlying storage (e.g. disk or platform vault error).PlatformNotSupportedException: Attempting to useSecureTokenStorageon unsupported platforms (e.g. Flutter Web in v1).TokenNotFoundException: When requesting a token but no session exists.TokenExpiredException: Token expired and no refresh token is available to renew it.TokenRefreshTransientException: Temporary failure (network failure, timeout, 5xx server error). Credentials are preserved.TokenRefreshRejectedException: Authentication-invalidating failure (401/403, revoked session, invalid grant). Credentials are cleared automatically.TokenInitializationException: Raised when initialization fails.AuthenticationRequiredException: Attempting an action requiring an authenticated user when unauthenticated or logged out.
10. Security Considerations & Realistic Guarantees
- No Plaintext Fallback:
SecureTokenStorageuses platform-native hardware keystores. If a platform cannot securely store credentials, an explicit exception is thrown. - Secrets Redaction:
TokenData.toString()printsTokenData(accessToken: [REDACTED], refreshToken: [REDACTED], ...). Tokens never appear in logs or exceptions. - Memory Security in Dart: Dart strings are managed by the Dart VM garbage collector. Like virtually all managed runtimes (Java, Swift, JavaScript), Dart does not provide a guaranteed mechanism to wipe arbitrary strings from memory immediately. This package minimizes memory residency and avoids unnecessary copying or string conversions.
- Transport Security (HTTPS): Client-side storage cannot compensate for unencrypted network traffic. All authentication endpoints must use HTTPS with valid TLS certificates.
- Backend Refresh Rotation: It is strongly recommended that your backend issues a new refresh token on every refresh invocation (refresh token rotation) and revokes old refresh tokens.
11. Testing & Mocking
Use MemoryTokenStorage and FakeClock to write deterministic tests without delays:
import 'package:flutter_test/flutter_test.dart';
import 'package:secure_token_manager/secure_token_manager.dart';
void main() {
test('refreshes token deterministically', () async {
final storage = MemoryTokenStorage();
final manager = TokenManager(
storage: storage,
refreshToken: (r) async => TokenData(accessToken: 'new_token'),
);
await manager.setTokens(TokenData(accessToken: 'old_token'));
expect(await manager.getValidAccessToken(), 'old_token');
});
}
12. Roadmap
- v1.x:
- Optional biometric challenge integration for high-security applications.
- Storage key migration helpers.
- v2.0:
- Web platform support with explicit security boundaries and Web Crypto API.
- Multi-account / multi-tenant concurrent session manager.
13. Contributing
Contributions are welcome! Please read CONTRIBUTING.md for details on code style, analyzer rules, and running tests.
14. License
This project is licensed under the MIT License - see the LICENSE file for details.
Libraries
- secure_token_manager
- A production-grade, platform-aware authentication token lifecycle manager for Flutter applications with single-flight concurrent refresh protection.