Web app session management is relatively forgiving: sessions live in HttpOnly cookies, the browser enforces same-origin policies, and HTTPS is the default. Mobile apps face a harder environment: tokens must be stored in user space, the app may run in the background for days without user interaction, and an extracted APK or IPA can be inspected by anyone. Getting session management right on mobile requires deliberate choices about storage, refresh strategy, and re-authentication.
Secure token storage
Never store tokens in UserDefaults (iOS) or SharedPreferences (Android). These are unencrypted and readable by other apps with the right permissions, or extractable from device backups. Use the platform's secure credential storage instead.
On iOS, the Keychain protects items with the device's hardware security module. Items can be protected with a kSecAttrAccessible attribute that controls when they can be accessed. For tokens that should only be readable when the device is unlocked, use kSecAttrAccessibleWhenUnlockedThisDeviceOnly.
// iOS: Store refresh token in Keychain
import Security
func storeRefreshToken(_ token: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.myapp",
kSecAttrAccount as String: "refresh_token",
kSecValueData as String: token.data(using: .utf8)!,
// Only accessible when device is unlocked, not synced to iCloud
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
// Delete existing item first
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
}
func loadRefreshToken() throws -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.myapp",
kSecAttrAccount as String: "refresh_token",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
Refresh token rotation
Refresh token rotation issues a new refresh token every time the old one is used. The server invalidates the previous token. This limits the window of exposure for a stolen refresh token: once the legitimate client uses it and receives the new token, the stolen copy is invalidated. If the stolen copy is used first, the server detects the reuse (the old token has already been rotated) and can terminate the entire token family.
// Server: handle token refresh with rotation
app.post('/auth/token', async (req, res) => {
const { grant_type, refresh_token } = req.body;
if (grant_type !== 'refresh_token') return res.status(400).json({ error: 'unsupported_grant_type' });
const stored = await db.refreshTokens.findOne({
token: hashToken(refresh_token),
revokedAt: null,
});
if (!stored) {
// Token not found — check if it was previously rotated
const reused = await db.refreshTokens.findOne({
token: hashToken(refresh_token),
revokedAt: { not: null },
});
if (reused) {
// Reuse detection: revoke the entire token family
await db.refreshTokens.updateMany(
{ familyId: reused.familyId },
{ revokedAt: new Date(), revokeReason: 'refresh_token_reuse_detected' }
);
// Optionally notify the user
logger.warn({ event: 'refresh_token_reuse', userId: reused.userId });
}
return res.status(401).json({ error: 'invalid_grant' });
}
// Issue new access token and rotated refresh token
const newRefreshToken = crypto.randomBytes(32).toString('hex');
await db.transaction(async (tx) => {
// Rotate: revoke old token
await tx.refreshTokens.update(
{ id: stored.id },
{ revokedAt: new Date(), revokeReason: 'rotated' }
);
// Issue new refresh token in the same family
await tx.refreshTokens.create({
token: hashToken(newRefreshToken),
userId: stored.userId,
familyId: stored.familyId,
});
});
const accessToken = await issueAccessToken(stored.userId);
res.json({ access_token: accessToken, refresh_token: newRefreshToken, token_type: 'Bearer' });
});
Biometric re-authentication
For sensitive actions — changing account settings, viewing billing information, approving a payment — you can require the user to re-authenticate with biometrics before proceeding. This is distinct from the initial login: the user is already in a valid session, but the action requires a fresh authentication gesture. On iOS, LAContext.evaluatePolicy with .deviceOwnerAuthenticationWithBiometrics presents Face ID or Touch ID.
// iOS: Require biometric confirmation before sensitive action
import LocalAuthentication
func requireBiometricConfirmation(reason: String) async throws {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
// Fall back to passcode if biometrics not available
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
throw AuthError.biometricNotAvailable
}
try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason)
return
}
try await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason)
}
// In the view model for a sensitive screen
func proceedWithPaymentUpdate() async {
do {
try await requireBiometricConfirmation(reason: "Confirm your identity to update payment method")
// Biometric passed — proceed with the action
await updatePaymentMethod()
} catch LAError.userCancel, LAError.userFallback {
// User cancelled — do not proceed
} catch {
showError("Authentication failed. Please try again.")
}
}
Certificate pinning
Certificate pinning (more accurately, certificate or public key pinning) prevents man-in-the-middle attacks by validating that the server's certificate matches a known value, rather than just validating that it is signed by any trusted CA. This is relevant for high-security mobile apps where a network attacker might install a custom root CA (via MDM on enterprise devices) to intercept TLS.
// iOS: Public key pinning using URLSession delegate
import CryptoKit
class PinnedURLSessionDelegate: NSObject, URLSessionDelegate {
// SHA-256 hashes of the pinned public keys
private let pinnedHashes: Set<String> = [
"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", // Current cert
"Vja/wNnB/+rPkm5A3BcCZBGJHxiDZJIBPxSqRJQ5oBM=", // Backup cert
]
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust,
let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0)
else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Extract and hash the public key
guard let publicKey = SecCertificateCopyKey(certificate),
let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) as Data?
else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let hash = SHA256.hash(data: publicKeyData).withUnsafeBytes {
Data($0).base64EncodedString()
}
if pinnedHashes.contains(hash) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}