session-management
SKILL.md
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:
// ❌ Unencrypted, backed up, exposed on jailbreak
UserDefaults.standard.set(accessToken, forKey: "accessToken")
UserDefaults.standard.set(refreshToken, forKey: "refreshToken")
// ✅ Use Keychain
try KeychainHelper.shared.save(accessToken, service: "auth", account: "accessToken")
try KeychainHelper.shared.save(refreshToken, service: "auth", account: "refreshToken")
NEVER log or print tokens:
// ❌ Tokens in console logs — security disaster
print("Token: \(accessToken)")
Logger.debug("Refresh token: \(refreshToken)")
// ✅ Log safely
Logger.debug("Token refreshed successfully") // No token content
Logger.debug("Token length: \(accessToken.count)") // Metadata only
NEVER hardcode secrets:
// ❌ Secrets in binary — extractable
let clientSecret = "abc123xyz789"
let apiKey = "sk-live-xxxxx"
// ✅ Use environment or server
// Fetch from server during OAuth flow
// Or use Info.plist with .gitignore for dev keys
let clientId = Bundle.main.infoDictionary?["CLIENT_ID"] as? String
Token Refresh
NEVER retry refresh infinitely:
// ❌ Infinite loop if refresh token is invalid
func refreshToken() async throws {
do {
let response = try await API.refresh(token: refreshToken)
storeTokens(response)
} catch {
try await refreshToken() // Recursive retry — infinite loop!
}
}
// ✅ Limited retries with backoff, then logout
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:
// ❌ Unnecessary API calls
func makeRequest(_ endpoint: Endpoint) async throws -> Data {
try await refreshAccessToken() // Refresh EVERY request!
return try await performRequest(endpoint)
}
// ✅ Refresh only when needed (expired or 401)
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:
// ❌ Partial cleanup — tokens still accessible
func logout() {
currentUser = nil
isAuthenticated = false
// Forgot to clear Keychain tokens!
}
// ✅ Complete cleanup
func logout() {
// Clear tokens
KeychainHelper.shared.deleteAll(service: keychainService)
// Clear memory
currentUser = nil
isAuthenticated = false
// Clear caches
URLCache.shared.removeAllCachedResponses()
// Clear cookies
HTTPCookieStorage.shared.removeCookies(since: .distantPast)
// Clear UserDefaults user data
let userKeys = ["userId", "userEmail", "userPreferences"]
userKeys.forEach { UserDefaults.standard.removeObject(forKey: $0) }
}
NEVER leave background tasks running after logout:
// ❌ Background refresh continues for logged-out user
func logout() {
clearTokens()
currentUser = nil
// Background refresh timer still running!
}
// ✅ Cancel all background work
func logout() {
// Cancel scheduled tasks
sessionRefreshTask?.cancel()
sessionRefreshTask = nil
// Cancel any pending requests
URLSession.shared.getAllTasks { tasks in
tasks.forEach { $0.cancel() }
}
// Clear data
clearTokens()
currentUser = nil
}
Keychain Security
NEVER use wrong accessibility level:
// ❌ Too permissive — accessible even when locked
kSecAttrAccessibleAlways // Deprecated and insecure!
kSecAttrAccessibleAlwaysThisDeviceOnly // Still too permissive
// ✅ Appropriate accessibility
// For tokens that need background access:
kSecAttrAccessibleAfterFirstUnlock
// For highly sensitive data (biometric):
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
NEVER ignore Keychain errors:
// ❌ Silent failure — user appears logged out
func getToken() -> String? {
let query = [...]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result) // Ignoring status!
return result as? String
}
// ✅ Handle errors properly
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 // No token stored
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()
}
// MARK: - Authentication
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() {
// Cancel background work
refreshTask?.cancel()
refreshTask = nil
// Clear Keychain
KeychainHelper.shared.deleteAll(service: keychainService)
// Clear state
currentUser = nil
isAuthenticated = false
// Clear caches
URLCache.shared.removeAllCachedResponses()
HTTPCookieStorage.shared.removeCookies(since: .distantPast)
}
// MARK: - Token Management
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)
}
// MARK: - Private
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 {
// Refresh 5 minutes before expiration
try? await Task.sleep(nanoseconds: 55 * 60 * 1_000_000_000) // 55 min
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
]
// Delete existing
SecItemDelete(query as CFDictionary)
// Add new
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()
// Add token
if let token = await sessionManager.getAccessToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await URLSession.shared.data(for: request)
// Handle 401 with retry
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 401 {
try await sessionManager.refreshAccessToken()
// Retry with new token
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 |
Weekly Installs
13
Repository
kaakati/rails-e…rise-devGitHub Stars
6
First Seen
Jan 25, 2026
Security Audits
Installed on
claude-code11
opencode11
gemini-cli10
codex10
antigravity9
github-copilot9