| name | session-management |
| description | Expert session decisions for iOS/tvOS: token storage security levels, refresh flow architectures, multi-session handling strategies, and logout cleanup requirements. Use when implementing authentication, debugging token issues, or designing session architecture. Trigger keywords: session, authentication, token, Keychain, refresh token, access token, JWT, OAuth2, logout, session expiration, KeychainHelper, SecItemAdd, kSecAttrAccessible |
| version | 3.0.0 |
Session Management — Expert Decisions
Expert decision frameworks for session management choices. Claude knows Keychain basics and OAuth concepts — this skill provides judgment calls for security levels, refresh strategies, and cleanup requirements.
Decision Trees
Token Storage Strategy
Where should you store authentication tokens?
├─ Access token (short-lived, <1hr)
│ └─ Keychain with kSecAttrAccessibleAfterFirstUnlock
│ Available after first unlock, survives restart
│
├─ Refresh token (long-lived)
│ └─ Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly
│ More secure, device-bound, requires unlock
│
├─ Session ID (server-side session)
│ └─ Keychain with kSecAttrAccessibleAfterFirstUnlock
│ Needs to work for background refreshes
│
├─ Temporary auth code (OAuth flow)
│ └─ Memory only (no persistence)
│ Used once, discarded immediately
│
└─ Remember me preference
└─ UserDefaults (not sensitive)
Just a boolean, not a credential
The trap: Storing tokens in UserDefaults. It's unencrypted, backed up to iCloud, and readable by jailbroken devices.
Token Refresh Architecture
How should you handle token refresh?
├─ Simple app, few API calls
│ └─ Refresh on 401 response
│ Reactive: refresh when expired
│
├─ Frequent API calls
│ └─ Proactive refresh before expiration
│ Schedule refresh 5 min before exp
│
├─ Real-time features (WebSocket)
│ └─ Background refresh + reconnect
│ Maintain connection continuity
│
├─ Offline-first app
│ └─ Longer token lifetime + retry queue
│ Queue requests when offline
│
└─ High-security app
└─ Short tokens + frequent refresh
Minimize exposure window
Multi-Session Architecture
How many sessions does your app support?
├─ Single device, single account
│ └─ Simple SessionManager singleton
│ Replace tokens on new login
│
├─ Single device, multiple accounts (switching)
│ └─ Account-keyed Keychain storage
│ Keychain items per account ID
│ Active account pointer
│
├─ Multiple devices, single account
│ └─ Server-side session management
│ Device tokens registered with server
│ Remote logout capability
│
└─ Multiple devices, multiple accounts
└─ Full session registry
Server tracks all device-account pairs
Cross-device session visibility
Logout Cleanup Scope
What needs clearing on logout?
├─ Always clear
│ └─ Tokens (Keychain)
│ └─ User object (memory)
│ └─ Authenticated state
│
├─ Usually clear
│ └─ URL cache (cached API responses)
│ └─ HTTP cookies
│ └─ User preferences tied to account
│
├─ Consider clearing
│ └─ Downloaded files (if user-specific)
│ └─ Core Data (if user-specific)
│ └─ Image cache (if contains private content)
│
└─ Usually keep
└─ App preferences (theme, language)
└─ Onboarding completion state
└─ Device registration
NEVER Do
Token Storage
NEVER store tokens in UserDefaults:
UserDefaults.standard.set(accessToken, forKey: "accessToken")
UserDefaults.standard.set(refreshToken, forKey: "refreshToken")
try KeychainHelper.shared.save(accessToken, service: "auth", account: "accessToken")
try KeychainHelper.shared.save(refreshToken, service: "auth", account: "refreshToken")
NEVER log or print tokens:
print("Token: \(accessToken)")
Logger.debug("Refresh token: \(refreshToken)")
Logger.debug("Token refreshed successfully")
Logger.debug("Token length: \(accessToken.count)")
NEVER hardcode secrets:
let clientSecret = "abc123xyz789"
let apiKey = "sk-live-xxxxx"
let clientId = Bundle.main.infoDictionary?["CLIENT_ID"] as? String
Token Refresh
NEVER retry refresh infinitely:
func refreshToken() async throws {
do {
let response = try await API.refresh(token: refreshToken)
storeTokens(response)
} catch {
try await refreshToken()
}
}
func refreshToken(attempt: Int = 0) async throws {
guard attempt < 3 else {
await MainActor.run { logout() }
throw SessionError.refreshFailed
}
do {
let response = try await API.refresh(token: refreshToken)
storeTokens(response)
} catch {
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000)
try await refreshToken(attempt: attempt + 1)
}
}
NEVER refresh on every request:
func makeRequest(_ endpoint: Endpoint) async throws -> Data {
try await refreshAccessToken()
return try await performRequest(endpoint)
}
func makeRequest(_ endpoint: Endpoint) async throws -> Data {
if isTokenExpired() {
try await refreshAccessToken()
}
let (data, response) = try await performRequest(endpoint)
if (response as? HTTPURLResponse)?.statusCode == 401 {
try await refreshAccessToken()
return try await performRequest(endpoint).0
}
return data
}
Logout
NEVER forget to clear sensitive data:
func logout() {
currentUser = nil
isAuthenticated = false
}
func logout() {
KeychainHelper.shared.deleteAll(service: keychainService)
currentUser = nil
isAuthenticated = false
URLCache.shared.removeAllCachedResponses()
HTTPCookieStorage.shared.removeCookies(since: .distantPast)
let userKeys = ["userId", "userEmail", "userPreferences"]
userKeys.forEach { UserDefaults.standard.removeObject(forKey: $0) }
}
NEVER leave background tasks running after logout:
func logout() {
clearTokens()
currentUser = nil
}
func logout() {
sessionRefreshTask?.cancel()
sessionRefreshTask = nil
URLSession.shared.getAllTasks { tasks in
tasks.forEach { $0.cancel() }
}
clearTokens()
currentUser = nil
}
Keychain Security
NEVER use wrong accessibility level:
kSecAttrAccessibleAlways
kSecAttrAccessibleAlwaysThisDeviceOnly
kSecAttrAccessibleAfterFirstUnlock
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
NEVER ignore Keychain errors:
func getToken() -> String? {
let query = [...]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result)
return result as? String
}
func getToken() throws -> String? {
let query = [...]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
guard let data = result as? Data,
let token = String(data: data, encoding: .utf8) else {
throw KeychainError.invalidData
}
return token
case errSecItemNotFound:
return nil
default:
throw KeychainError.unableToRetrieve(status: status)
}
}
Essential Patterns
Secure SessionManager
@MainActor
final class SessionManager: ObservableObject {
static let shared = SessionManager()
@Published private(set) var isAuthenticated = false
@Published private(set) var currentUser: User?
private let keychainService = "com.app.auth"
private var refreshTask: Task<Void, Never>?
private init() {
restoreSession()
}
func login(email: String, password: String) async throws {
let response = try await AuthAPI.login(email: email, password: password)
try storeTokens(access: response.accessToken, refresh: response.refreshToken)
currentUser = response.user
isAuthenticated = true
scheduleTokenRefresh()
}
func logout() {
refreshTask?.cancel()
refreshTask = nil
KeychainHelper.shared.deleteAll(service: keychainService)
currentUser = nil
isAuthenticated = false
URLCache.shared.removeAllCachedResponses()
HTTPCookieStorage.shared.removeCookies(since: .distantPast)
}
func getAccessToken() -> String? {
KeychainHelper.shared.read(service: keychainService, account: "accessToken")
}
func refreshAccessToken() async throws {
guard let refreshToken = KeychainHelper.shared.read(
service: keychainService, account: "refreshToken"
) else {
throw SessionError.noRefreshToken
}
let response = try await AuthAPI.refresh(token: refreshToken)
try storeTokens(access: response.accessToken, refresh: response.refreshToken)
}
private func storeTokens(access: String, refresh: String) throws {
try KeychainHelper.shared.save(access, service: keychainService, account: "accessToken")
try KeychainHelper.shared.save(refresh, service: keychainService, account: "refreshToken")
}
private func restoreSession() {
guard let _ = getAccessToken() else { return }
isAuthenticated = true
Task { try? await loadUserProfile() }
}
private func scheduleTokenRefresh() {
refreshTask?.cancel()
refreshTask = Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 55 * 60 * 1_000_000_000)
guard !Task.isCancelled else { return }
do {
try await refreshAccessToken()
} catch {
await MainActor.run { logout() }
return
}
}
}
}
}
Secure KeychainHelper
final class KeychainHelper {
static let shared = KeychainHelper()
private init() {}
func save(_ value: String, service: String, account: String) throws {
guard let data = value.data(using: .utf8) else {
throw KeychainError.invalidData
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status: status)
}
}
func read(service: String, account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
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,
let string = String(data: data, encoding: .utf8) else {
return nil
}
return string
}
func delete(service: String, account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
SecItemDelete(query as CFDictionary)
}
func deleteAll(service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service
]
SecItemDelete(query as CFDictionary)
}
}
enum KeychainError: LocalizedError {
case invalidData
case saveFailed(status: OSStatus)
case readFailed(status: OSStatus)
var errorDescription: String? {
switch self {
case .invalidData: return "Invalid data format"
case .saveFailed(let status): return "Keychain save failed: \(status)"
case .readFailed(let status): return "Keychain read failed: \(status)"
}
}
}
Auto-Retry Network Client
actor NetworkClient {
private let sessionManager: SessionManager
init(sessionManager: SessionManager = .shared) {
self.sessionManager = sessionManager
}
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
var request = try endpoint.asURLRequest()
if let token = await sessionManager.getAccessToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 401 {
try await sessionManager.refreshAccessToken()
if let newToken = await sessionManager.getAccessToken() {
request.setValue("Bearer \(newToken)", forHTTPHeaderField: "Authorization")
let (retryData, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(T.self, from: retryData)
}
}
return try JSONDecoder().decode(T.self, from: data)
}
}
Quick Reference
Keychain Accessibility Levels
| Level | When Accessible | Use For |
|---|
| WhenUnlocked | Device unlocked | Foreground-only tokens |
| AfterFirstUnlock | After first unlock | Background refresh tokens |
| WhenUnlockedThisDeviceOnly | Unlocked, no backup | Highly sensitive data |
| WhenPasscodeSetThisDeviceOnly | Passcode set | Biometric-protected |
Logout Cleanup Checklist
| Data | Storage | Clear On Logout? |
|---|
| Access token | Keychain | ✅ Always |
| Refresh token | Keychain | ✅ Always |
| User profile | Memory | ✅ Always |
| API cache | URLCache | ✅ Usually |
| Cookies | HTTPCookieStorage | ✅ Usually |
| User preferences | UserDefaults | ⚠️ Maybe |
| Downloaded files | FileManager | ⚠️ If user-specific |
| App settings | UserDefaults | ❌ Usually keep |
Token Refresh Strategies
| Strategy | When to Use | Implementation |
|---|
| On 401 | Simple apps | Retry after refresh |
| Proactive | Frequent API calls | Timer before expiration |
| Background | Real-time features | BGAppRefreshTask |
Red Flags
| Smell | Problem | Fix |
|---|
| Tokens in UserDefaults | Unencrypted storage | Use Keychain |
| Logging token values | Security exposure | Log metadata only |
| Infinite refresh retry | DoS on invalid token | Limited retries + logout |
| Refresh on every request | Unnecessary API calls | Check expiration first |
| Partial logout cleanup | Data leakage | Clear all sensitive data |
| Ignoring Keychain errors | Silent failures | Handle status codes |
| kSecAttrAccessibleAlways | Too permissive | Use AfterFirstUnlock |
| Background tasks after logout | Stale operations | Cancel on logout |