Skip to main content

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:

  1. Store tokens securely using Keychain/Keystore/Secure Storage
  2. Automatically refresh tokens on 401 responses
  3. Use synchronization to prevent multiple concurrent refreshes
  4. Clear tokens and navigate to login on refresh failure
  5. Validate user inputs before making API calls
  6. Check authentication state on app launch
PlatformSecure StorageToken RefreshSession Check
iOSKeychainCustom interceptorTokenStore check
AndroidEncryptedSharedPreferencesOkHttp AuthenticatorViewModel state
FlutterFlutterSecureStorageDio InterceptorBloc state