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 401 anyway (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"]

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: Bearer to authed requests via a requiresAuth flag.
  • Built a single-flight TokenProvider actor 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 a TokenStore protocol so
  • Refresh proactively (check expiry) and reactively (retry on 401 once); the whole dance stays
  • Serialize refresh through an actor so concurrent 401s trigger one refresh (single-flight)
  • A failed refresh means the session is over: clear tokens + sign out, handled once in the client.