Authentication & Token Management
Overview
This document covers authentication handling, token storage, and automatic token refresh for mobile applications.
Token Storage
Store tokens securely using platform-specific secure storage mechanisms.
iOS - Keychain
// App/Core/Networking/TokenStore/KeychainTokenStore.swift
protocol TokenStore {
var accessToken: String? { get }
var refreshToken: String? { get }
func save(_ pair: TokenPair)
func clear()
}
final class KeychainTokenStore: TokenStore {
private let accessTokenKey = "access_token"
private let refreshTokenKey = "refresh_token"
var accessToken: String? {
read(key: accessTokenKey)
}
var refreshToken: String? {
read(key: refreshTokenKey)
}
func save(_ pair: TokenPair) {
write(key: accessTokenKey, value: pair.accessToken)
write(key: refreshTokenKey, value: pair.refreshToken)
}
func clear() {
delete(key: accessTokenKey)
delete(key: refreshTokenKey)
}
private func write(key: String, value: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: value.data(using: .utf8)!,
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
private func read(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
}
private func delete(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
}
Android - EncryptedSharedPreferences
// app/core/network/TokenStore/SecureTokenStore.kt
interface TokenStore {
fun getAccessToken(): String?
fun getRefreshToken(): String?
fun save(accessToken: String, refreshToken: String)
fun clear()
}
class SecureTokenStore @Inject constructor(
@ApplicationContext private val context: Context
) : TokenStore {
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
override fun getAccessToken(): String? =
prefs.getString(KEY_ACCESS_TOKEN, null)
override fun getRefreshToken(): String? =
prefs.getString(KEY_REFRESH_TOKEN, null)
override fun save(accessToken: String, refreshToken: String) {
prefs.edit {
putString(KEY_ACCESS_TOKEN, accessToken)
putString(KEY_REFRESH_TOKEN, refreshToken)
}
}
override fun clear() {
prefs.edit { clear() }
}
companion object {
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
}
}
Flutter - Secure Storage
// lib/core/network/token_store/secure_token_store.dart
abstract class TokenStore {
String? get accessToken;
String? get refreshToken;
Future<void> save(TokenPair pair);
Future<void> clear();
}
class SecureTokenStore implements TokenStore {
final FlutterSecureStorage _storage;
static const _accessTokenKey = 'access_token';
static const _refreshTokenKey = 'refresh_token';
String? _cachedAccessToken;
String? _cachedRefreshToken;
SecureTokenStore(this._storage);
// Call initialize() once at app startup (e.g., in main) before reading cached tokens.
Future<void> initialize() async {
_cachedAccessToken = await _storage.read(key: _accessTokenKey);
_cachedRefreshToken = await _storage.read(key: _refreshTokenKey);
}
String? get accessToken => _cachedAccessToken;
String? get refreshToken => _cachedRefreshToken;
Future<void> save(TokenPair pair) async {
await Future.wait([
_storage.write(key: _accessTokenKey, value: pair.accessToken),
_storage.write(key: _refreshTokenKey, value: pair.refreshToken),
]);
_cachedAccessToken = pair.accessToken;
_cachedRefreshToken = pair.refreshToken;
}
Future<void> clear() async {
await Future.wait([
_storage.delete(key: _accessTokenKey),
_storage.delete(key: _refreshTokenKey),
]);
_cachedAccessToken = null;
_cachedRefreshToken = null;
}
}
Token Refresh
Implement automatic token refresh when the API returns 401 Unauthorized.
iOS - URLSession with Token Refresh
// App/Core/Networking/AuthenticatedAPIClient.swift
actor TokenRefreshCoordinator {
private var refreshTask: Task<Void, Error>?
func refreshIfNeeded(
tokenStore: TokenStore,
authService: AuthService
) async throws {
// If a refresh is already in progress, await it.
if let refreshTask {
try await refreshTask.value
return
}
guard let refreshToken = tokenStore.refreshToken else {
throw AppError.unauthorized
}
let task = Task {
do {
let tokenPair = try await authService.refresh(using: refreshToken)
tokenStore.save(tokenPair)
} catch {
tokenStore.clear()
throw AppError.unauthorized
}
}
refreshTask = task
defer { refreshTask = nil }
try await task.value
}
}
final class AuthenticatedAPIClient: APIClient {
private let baseClient: APIClient
private let tokenStore: TokenStore
private let authService: AuthService
private let refreshCoordinator = TokenRefreshCoordinator()
init(baseClient: APIClient, tokenStore: TokenStore, authService: AuthService) {
self.baseClient = baseClient
self.tokenStore = tokenStore
self.authService = authService
}
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
do {
return try await performRequest(endpoint)
} catch APIError.unauthorized {
try await refreshCoordinator.refreshIfNeeded(
tokenStore: tokenStore,
authService: authService
)
return try await performRequest(endpoint)
}
}
private func performRequest<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
var modified = endpoint
if let token = tokenStore.accessToken {
modified = endpoint.withAuthorization("Bearer \(token)")
}
return try await baseClient.request(modified)
}
}
Android - OkHttp Authenticator
// app/core/network/TokenAuthenticator.kt
class TokenAuthenticator @Inject constructor(
private val tokenStore: TokenStore,
private val authApi: AuthApi
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
// Avoid infinite loops
if (responseCount(response) >= 2) return null
val requestAccessToken = response.request.header("Authorization")
?.removePrefix("Bearer ")
// If another request already refreshed the token, retry with the latest one.
val latestAccessToken = tokenStore.getAccessToken()
if (latestAccessToken != null && latestAccessToken != requestAccessToken) {
return response.request.newBuilder()
.header("Authorization", "Bearer $latestAccessToken")
.build()
}
// Refresh token (use an AuthApi backed by a separate OkHttpClient without this authenticator)
val refreshToken = tokenStore.getRefreshToken() ?: return null
val tokenPair = runCatching {
authApi.refresh(RefreshRequest(refreshToken))
}.getOrElse {
tokenStore.clear()
return null
}
tokenStore.save(tokenPair.accessToken, tokenPair.refreshToken)
return response.request.newBuilder()
.header("Authorization", "Bearer ${tokenPair.accessToken}")
.build()
}
private fun responseCount(response: Response): Int {
var count = 1
var prior = response.priorResponse
while (prior != null) {
count++
prior = prior.priorResponse
}
return count
}
}
Flutter - Dio Interceptor
// lib/core/network/auth_interceptor.dart
class AuthInterceptor extends Interceptor {
final Dio _dio;
final TokenStore _tokenStore;
final AuthApi _authApi;
Future<void>? _refreshing;
AuthInterceptor(this._dio, this._tokenStore, this._authApi);
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = _tokenStore.accessToken;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode != 401) {
return handler.next(err);
}
final refreshToken = _tokenStore.refreshToken;
if (refreshToken == null) {
await _tokenStore.clear();
return handler.next(err);
}
// Ensure only one refresh happens at a time.
_refreshing ??= _refresh(refreshToken);
try {
await _refreshing;
final newAccessToken = _tokenStore.accessToken;
if (newAccessToken == null) {
return handler.next(err);
}
final opts = err.requestOptions;
final retried = await _dio.fetch<dynamic>(
opts.copyWith(
headers: <String, dynamic>{
...opts.headers,
'Authorization': 'Bearer $newAccessToken',
},
),
);
return handler.resolve(retried);
} catch (_) {
await _tokenStore.clear();
return handler.next(err);
} finally {
_refreshing = null;
}
}
Future<void> _refresh(String refreshToken) async {
final pair = await _authApi.refresh(refreshToken);
await _tokenStore.save(pair);
}
}
Authentication Flow
Login
// iOS - LoginUseCase
final class LoginUseCase {
private let authRepository: AuthRepository
func execute(email: String, password: String) async throws -> User {
// Validate inputs
guard email.contains("@") else {
throw AppError.invalidInput("Invalid email format")
}
guard password.count >= 8 else {
throw AppError.invalidInput("Password must be at least 8 characters")
}
// Login and get tokens
let tokenPair = try await authRepository.login(email: email, password: password)
// Fetch user profile
let user = try await authRepository.getCurrentUser()
return user
}
}
// Android - LoginUseCase
class LoginUseCase @Inject constructor(
private val authRepository: AuthRepository
) {
suspend operator fun invoke(
email: String,
password: String
): Result<User> {
// Validate inputs
if (!email.contains("@")) {
return Result.failure(AppError.InvalidInput("Invalid email format"))
}
if (password.length < 8) {
return Result.failure(AppError.InvalidInput("Password must be at least 8 characters"))
}
return authRepository.login(email, password)
.mapCatching { authRepository.getCurrentUser().getOrThrow() }
}
}
// Flutter - LoginUseCase
class LoginUseCase {
final AuthRepository _repository;
const LoginUseCase(this._repository);
Future<User> call(String email, String password) async {
// Validate inputs
if (!email.contains('@')) {
throw const InvalidInput('Invalid email format');
}
if (password.length < 8) {
throw const InvalidInput('Password must be at least 8 characters');
}
// Login and get tokens
await _repository.login(email, password);
// Fetch user profile
return _repository.getCurrentUser();
}
}
Logout
// iOS
func logout() async {
tokenStore.clear()
// Navigate to login
}
// Android
suspend fun logout() {
tokenStore.clear()
// Navigate to login
}
// Flutter
Future<void> logout() async {
await tokenStore.clear();
// Navigate to login
}
Session Management
Check authentication state on app launch.
// iOS
@MainActor
final class AppViewModel: ObservableObject {
@Published var isAuthenticated = false
private let tokenStore: TokenStore
func checkAuth() {
isAuthenticated = tokenStore.accessToken != nil
}
}
// Android
@HiltViewModel
class AppViewModel @Inject constructor(
private val tokenStore: TokenStore
) : ViewModel() {
val isAuthenticated: StateFlow<Boolean> = flow {
emit(tokenStore.getAccessToken() != null)
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false
)
}
// Flutter
class AppBloc extends Bloc<AppEvent, AppState> {
final TokenStore _tokenStore;
AppBloc({required TokenStore tokenStore})
: _tokenStore = tokenStore,
super(const AppUnauthenticated()) {
on<CheckAuthStatus>(_onCheckAuthStatus);
}
Future<void> _onCheckAuthStatus(
CheckAuthStatus event,
Emitter<AppState> emit,
) async {
final isAuthenticated = _tokenStore.accessToken != null;
if (isAuthenticated) {
emit(const AppAuthenticated());
} else {
emit(const AppUnauthenticated());
}
}
}
Summary
Authentication Best Practices:
- Store tokens securely using Keychain/Keystore/Secure Storage
- Automatically refresh tokens on 401 responses
- Use synchronization to prevent multiple concurrent refreshes
- Clear tokens and navigate to login on refresh failure
- Validate user inputs before making API calls
- Check authentication state on app launch
| Platform | Secure Storage | Token Refresh | Session Check |
|---|---|---|---|
| iOS | Keychain | Custom interceptor | TokenStore check |
| Android | EncryptedSharedPreferences | OkHttp Authenticator | ViewModel state |
| Flutter | FlutterSecureStorage | Dio Interceptor | Bloc state |