A Production Networking Layer: Auth Tokens, Retry, Keychain and Face ID

Advanced iOS Development: Concurrency, Architecture, Security and Release · Networking and Security · 10 min read · Updated

Safe API calls

  1. A view model asks the client for an endpoint. It never builds a URL.
  2. The client builds the request and asks the token store for a valid token.
  3. The token comes from the Keychain, the encrypted store for small secrets.
  4. The client sends the request. A temporary server error is retried with a delay, a client error is not.

The weather call in the beginner course was fine for a demo. A real app calls dozens of endpoints, signs requests with a token that expires, survives bad connections, and must keep that token somewhere safe. If each screen builds its own request, the code becomes copy and paste, and bugs multiply. This lesson builds the layer that sits between screens and the network, and then the safe storage and sign-in pieces around it.

Describe endpoints as values

Instead of building URLs inside each view model, describe each call in one place, as a small type. Then one client sends them all.

import Foundation

enum HTTPMethod: String {
    case get = "GET", post = "POST", put = "PUT", delete = "DELETE"
}

protocol Endpoint {
    var path: String { get }
    var method: HTTPMethod { get }
    var queryItems: [URLQueryItem] { get }
    var body: Data? { get }
}

extension Endpoint {
    var method: HTTPMethod { .get }
    var queryItems: [URLQueryItem] { [] }
    var body: Data? { nil }
}

struct ListExpenses: Endpoint {
    let path = "/v1/expenses"
    let queryItems: [URLQueryItem]
    init(month: String) { queryItems = [URLQueryItem(name: "month", value: month)] }
}

The protocol extension from lesson 1 supplies defaults, so a simple endpoint needs only a path. Adding an endpoint is now a few lines in one file, and a view model never sees a URL.

One client for all calls

enum NetworkError: Error {
    case badURL
    case badStatus(Int)
    case unauthorized
}

struct APIClient {
    let baseURL: URL
    let session: URLSession
    let auth: AuthTokenProviding

    func send<T: Decodable>(_ endpoint: some Endpoint, as type: T.Type = T.self) async throws -> T {
        var attempt = 0
        while true {
            do {
                return try await perform(endpoint)
            } catch let error as NetworkError where error.isRetryable && attempt < 2 {
                attempt += 1
                try await Task.sleep(for: .seconds(pow(2, Double(attempt))))   // 2 s, then 4 s
            }
        }
    }

    private func perform<T: Decodable>(_ endpoint: some Endpoint) async throws -> T {
        guard var parts = URLComponents(url: baseURL.appendingPathComponent(endpoint.path),
                                        resolvingAgainstBaseURL: false) else { throw NetworkError.badURL }
        if !endpoint.queryItems.isEmpty { parts.queryItems = endpoint.queryItems }
        guard let url = parts.url else { throw NetworkError.badURL }

        var request = URLRequest(url: url, timeoutInterval: 20)
        request.httpMethod = endpoint.method.rawValue
        request.httpBody = endpoint.body
        let token = try await auth.validToken()
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

        let (data, response) = try await session.data(for: request)
        guard let http = response as? HTTPURLResponse else { throw NetworkError.badStatus(-1) }
        switch http.statusCode {
        case 200..<300: return try JSONDecoder().decode(T.self, from: data)
        case 401: throw NetworkError.unauthorized
        default: throw NetworkError.badStatus(http.statusCode)
        }
    }
}

extension NetworkError {
    var isRetryable: Bool {
        if case .badStatus(let code) = self { return code >= 500 }
        return false
    }
}
  • Retry only what can succeed on a second try. Server errors (5xx) and timeouts may. A 400 or 404 will fail again, so do not retry them.
  • Back off. Wait longer after each failure, here 2 s and then 4 s. Hammering a struggling server makes it worse.
  • Never retry a request that changes data (a payment, a purchase) unless the server supports an *idempotency key*, so a repeat cannot charge twice.
  • Set a timeout so a slow connection ends in an error and not an endless spinner.

Refreshing the token once, not ten times

An access token expires, and a refresh token gets a new one. If ten requests start at once with an expired token, you do not want ten refresh calls. An actor is the right tool: it holds one shared refresh task, and every caller waits for the same one.

protocol AuthTokenProviding {
    func validToken() async throws -> String
}

actor TokenStore: AuthTokenProviding {
    private var accessToken: String?
    private var expiry: Date = .distantPast
    private var refreshTask: Task<String, Error>?

    func validToken() async throws -> String {
        if let accessToken, expiry > Date().addingTimeInterval(30) { return accessToken }
        if let refreshTask { return try await refreshTask.value }

        let task = Task { try await self.refresh() }
        refreshTask = task
        defer { refreshTask = nil }
        return try await task.value
    }

    private func refresh() async throws -> String {
        // call your server with the refresh token from the Keychain, then:
        let newToken = "new-access-token"
        accessToken = newToken
        expiry = Date().addingTimeInterval(15 * 60)
        return newToken
    }
}

The Keychain: where secrets live

Never keep tokens, passwords or keys in UserDefaults, in a plain file or in the code. Those are easy to read from a backup or a device that has been opened. The Keychain is the system's encrypted store for small secrets, and it is the correct place. Its API is old and written in C, so most teams wrap it in a small type, as below.

import Foundation
import Security

enum KeychainError: Error { case failed(OSStatus) }

enum Keychain {
    private static func baseQuery(_ account: String) -> [String: Any] {
        [kSecClass as String: kSecClassGenericPassword,
         kSecAttrService as String: "com.example.expensetracker",
         kSecAttrAccount as String: account]
    }

    static func save(_ data: Data, account: String) throws {
        SecItemDelete(baseQuery(account) as CFDictionary)
        var query = baseQuery(account)
        query[kSecValueData as String] = data
        query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else { throw KeychainError.failed(status) }
    }

    static func load(account: String) throws -> Data? {
        var query = baseQuery(account)
        query[kSecReturnData as String] = true
        query[kSecMatchLimit as String] = kSecMatchLimitOne
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        if status == errSecItemNotFound { return nil }
        guard status == errSecSuccess else { throw KeychainError.failed(status) }
        return result as? Data
    }

    static func delete(account: String) {
        SecItemDelete(baseQuery(account) as CFDictionary)
    }
}

Replace the service name with your own bundle identifier. The setting WhenUnlockedThisDeviceOnly means that the item is readable only while the phone is unlocked and is not copied to other devices through a backup. Log out by deleting the item.

Face ID and Touch ID

The LocalAuthentication framework asks the system to check the person's face or fingerprint. You never see the biometric data. You only learn whether it worked. You must add a short Face ID usage description to your app's Info settings, or the app will be stopped when it asks.

import LocalAuthentication

func unlockWithBiometrics() async -> Bool {
    let context = LAContext()
    var error: NSError?
    guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { return false }
    do {
        return try await context.evaluatePolicy(
            .deviceOwnerAuthentication,
            localizedReason: "Unlock your expenses")
    } catch {
        return false
    }
}

.deviceOwnerAuthentication lets the person fall back to the device passcode if the face or finger does not work. That is kinder and safer than blocking them.

Sign in with Apple, and other rules

  • Sign in with Apple lets people create an account without sharing their email address. SwiftUI has a ready-made SignInWithAppleButton, and it needs the *Sign in with Apple* capability in your target. Apple's App Review Guidelines may require you to offer it if your app offers other third-party sign-in options. Read the current guideline before you submit.
  • Account deletion: Apple requires apps that let people create an account to also let them delete it from inside the app. Check the current rules and plan this early.
  • HTTPS only. App Transport Security blocks plain http. Avoid turning it off.
  • Certificate pinning (only trusting your own server's certificate) can add safety for banking-like apps, but it also breaks when you change certificates. Use it only if you can manage that.
  • Do not log secrets. Never print tokens or passwords, even in debug builds.

Common mistakes

  • Tokens in UserDefaults.
  • Retrying a payment request automatically.
  • Ten parallel refresh calls, because the refresh is not shared.
  • Showing the user a raw error such as badStatus(500). Map errors to messages a person can act on.

Your task

  1. Make Endpoint, three endpoints of your own and the APIClient. Point it at any test server you have or at a local mock.
  2. Write the TokenStore actor and test it by starting five requests at once. Print how many times refresh() ran. It should be once.
  3. Store a fake token in the Keychain, read it back, then delete it.
  4. Add a biometric unlock before a *settings* screen, and add the Face ID usage description.

How to check it worked

  • A 503 response is retried twice with a delay, and a 404 is not retried.
  • Five simultaneous calls cause one refresh.
  • The token survives an app restart and is gone after logout.

Frequently asked questions

Where should an iOS app store an access token?

In the Keychain, the system's encrypted store for small secrets. Do not use UserDefaults or plain files, which are easy to read from a backup.

Which failed requests should be retried automatically?

Ones that can succeed on a second try, such as server errors (5xx) and timeouts, with a growing delay. Do not retry client errors such as 400 or 404, and do not retry requests that change money or data without an idempotency key.

Is Sign in with Apple required?

Apple's App Review Guidelines may require it if your app offers other third-party sign-in options, and they also require account deletion inside apps that create accounts. Rules change, so read the current guidelines before you submit.