Chapter 6 — JWT auth: token storage & refreshing
Where tokens must live: the Keychain, not UserDefaults
- Not
UserDefaults. It's a plist file, stored unencrypted; anyone with file access (a jailbroken - The Keychain. Apple's encrypted, hardware-backed secure store, designed exactly for credentials.
// Chirp/Networking/Keychain.swift
import Foundation
import Security
enum Keychain {
static func save(_ data: Data, account: String, service: String = "com.chirp.tokens") throws {
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(base as CFDictionary) // replace any existing item
var attributes = base
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(attributes as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandled(status) }
}
static func load(account: String, service: String = "com.chirp.tokens") -> Data? {
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?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
return result as? Data
}
static func delete(account: String, service: String = "com.chirp.tokens") {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service, kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
}
}
enum KeychainError: Error { case unhandled(OSStatus) }
// Chirp/Networking/KeychainTokenStore.swift
final class KeychainTokenStore: TokenStore, @unchecked Sendable {
private let account = "tokenBundle"
func save(_ tokens: TokenBundle) throws {
try Keychain.save(JSONEncoder().encode(tokens), account: account)
}
func load() -> TokenBundle? {
Keychain.load(account: account).flatMap { try? JSONDecoder().decode(TokenBundle.self, from: $0) }
}
func clear() { Keychain.delete(account: account) }
}
Attaching the access token to requests
// add to Endpoint<Response>
var requiresAuth: Bool = true // public endpoints (login/register/refresh) set this false
The refresh problem, stated precisely
- Proactively: before sending a request, if the stored access token is expired (or about to be),
- Reactively: if a request comes back
401anyway (the token was revoked, or expired between check
A single-flight token provider (actor)
// Chirp/Networking/TokenProvider.swift
import Foundation
actor TokenProvider {
private let store: TokenStore
private let refresh: (String) async throws -> TokenResponse // calls POST /auth/refresh
private var refreshTask: Task<TokenBundle, Error>? // the in-flight refresh, if any
init(store: TokenStore, refresh: @escaping (String) async throws -> TokenResponse) {
self.store = store
self.refresh = refresh
}
/// A currently-valid access token, refreshing if needed.
func validAccessToken() async throws -> String {
guard let bundle = store.load() else { throw APIError.notLoggedIn }
// Refresh proactively if it's expired or within 30s of it (clock-skew cushion).
if bundle.accessTokenExpiry > Date().addingTimeInterval(30) {
return bundle.accessToken
}
return try await refreshBundle().accessToken
}
/// Force a refresh (called reactively on a 401). Single-flight.
@discardableResult
func refreshBundle() async throws -> TokenBundle {
if let existing = refreshTask { // a refresh is already running — await THAT one
return try await existing.value
}
let task = Task { () throws -> TokenBundle in
defer { refreshTask = nil } // clear when done so the next expiry can refresh
guard let current = store.load() else { throw APIError.notLoggedIn }
let response = try await refresh(current.refreshToken) // POST /auth/refresh
let bundle = TokenBundle(
accessToken: response.accessToken,
refreshToken: response.refreshToken,
accessTokenExpiry: Date().addingTimeInterval(TimeInterval(response.expiresIn)))
try store.save(bundle) // persist the rotated pair
return bundle
}
refreshTask = task
return try await task.value
}
func clear() { store.clear() }
}
flowchart TB
R1["request 1 (401)"] --> P{TokenProvider.refreshBundle}
R2["request 2 (401)"] --> P
R3["request 3 (401)"] --> P
P -->|"first caller"| T["one refresh Task
POST /auth/refresh"] P -->|"others await"| T T -->|"new token bundle"| All["all 3 retry with fresh token"]
POST /auth/refresh"] P -->|"others await"| T T -->|"new token bundle"| All["all 3 retry with fresh token"]
Wiring refresh into the client
// Chirp/Networking/APIClient.swift (send, updated)
func send<Response>(_ endpoint: Endpoint<Response>) async throws -> Response {
var request = try makeRequest(for: endpoint)
if endpoint.requiresAuth {
request.setValue("Bearer \(try await tokenProvider.validAccessToken())",
forHTTPHeaderField: "Authorization")
}
let (data, response) = try await session.data(for: request)
// On 401 for an authed request, refresh once and retry.
if endpoint.requiresAuth, (response as? HTTPURLResponse)?.statusCode == 401 {
do {
let bundle = try await tokenProvider.refreshBundle()
request.setValue("Bearer \(bundle.accessToken)", forHTTPHeaderField: "Authorization")
let (retryData, retryResponse) = try await session.data(for: request)
try validate(retryResponse, data: retryData)
return try decode(retryData)
} catch {
// Refresh failed → the refresh token is dead. Sign the user out.
await tokenProvider.clear()
await sessionManager.logout()
throw APIError.notLoggedIn
}
}
try validate(response, data: data)
return try decode(data)
}
This is a first, direct implementation. In the next chapter we generalize "attach a header," "retry on condition," and "refresh" into a reusable interceptor pipeline, so this logic becomes a composable piece rather than special-cased inside
send. Building it directly first makes the abstraction concrete.
Logging out cleanly
What we built
- Stored tokens securely in the Keychain (encrypted, device-protected) behind the existing
- Attached
Authorization: Bearerto authed requests via arequiresAuthflag. - Built a single-flight
TokenProvideractor that refreshes proactively (before expiry) and - Made the client retry the original request after refresh, and log out cleanly when refresh
Mental model to take away
- Credentials go in the Keychain, never
UserDefaults; hide it behind aTokenStoreprotocol so - Refresh proactively (check expiry) and reactively (retry on
401once); the whole dance stays - Serialize refresh through an
actorso concurrent401s trigger one refresh (single-flight) - A failed refresh means the session is over: clear tokens + sign out, handled once in the client.