DEV Community

Cover image for Modern Networking in iOS with URLSession and async/await – Part 2
Mark Kazakov
Mark Kazakov

Posted on

Modern Networking in iOS with URLSession and async/await – Part 2

In Part 1 we built a clean networking layer using Swift’s modern concurrency and URLSession. The example endpoints were public and didn’t require authentication. Real-world apps, however, usually require you to authenticate users and attach short-lived access tokens to every request. These tokens eventually expire, and we need a way to obtain a new access token without forcing the user to log in again. In this part we’ll walk through how to implement a secure and robust token handling mechanism on top of the networking client from Part 1.

Understanding access and refresh tokens

  • Access tokens grant access to protected APIs. They are typically short‑lived (minutes to hours) so that an attacker only has a limited window if the token is compromised.
  • Refresh tokens are credentials that allow the client to request a new access token. Because they can be used to mint new access tokens, they need to be protected as if they were user passwords.

Token model

import Foundation

/// A container for access/refresh tokens and their expiration date.
/// Conforms to Codable so it can be encoded to and decoded from JSON.
struct TokenBundle: Codable {
    let accessToken: String
    let refreshToken: String
    let expiresAt: Date

    /// Returns true if the access token is expired.
    var isExpired: Bool {
        return expiresAt <= Date()
    }
}
Enter fullscreen mode Exit fullscreen mode

This struct mirrors the JSON payload returned by your authentication server (e.g. { "access_token":…, "refresh_token":…, "expires_at":… }). The isExpired is a computed property that helps us decide when to refresh.

Secure persistence - Keychain

Let's create a helper for storing and retrieving tokens:

import Foundation
import Security

/// A simple helper for storing and retrieving data from the Keychain.
/// This stores and retrieves a single `TokenBundle` under a fixed key.
/// You can generalize it if you need to store more items.
enum KeychainService {

    /// Errors thrown by keychain operations.
    enum KeychainError: Error {
        case unhandled(status: OSStatus)
    }

    /// Change this to your app’s bundle identifier to avoid key collisions.
    private static let service = "pro.mobile.dev.ModernNetworking"
    private static let account = "authTokens"

    /// Saves the given token bundle to the keychain, overwriting any existing value.
    static func save(_ tokens: TokenBundle) throws {
        let data = try JSONEncoder().encode(tokens)
        // Remove any existing entry.
        try? delete()
        // Add the new entry.
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecValueData as String: data
        ]
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.unhandled(status: status)
        }
    }

    /// Loads the token bundle from the keychain, or returns nil if no entry exists.
    static func load() throws -> TokenBundle? {
        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 item: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        if status == errSecItemNotFound {
            return nil
        }
        guard status == errSecSuccess, let data = item as? Data else {
            throw KeychainError.unhandled(status: status)
        }
        return try JSONDecoder().decode(TokenBundle.self, from: data)
    }

    /// Removes the token bundle from the keychain.
    static func delete() throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]
        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unhandled(status: status)
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

This helper encodes a TokenBundle to Data, stores it under a single key in the Keychain, and decodes it back to a TokenBundle when needed. It also includes a delete() method to clear the stored tokens when the user logs out.

Concurrency‑safe token management

When multiple network requests need a valid token simultaneously, we must avoid refreshing the token more than once at the same time.
Using a Swift actor ensures that only one refresh call happens concurrently.

import Foundation

/// Manages access and refresh tokens.
/// Uses an actor to serialize token access and refresh operations safely.
actor AuthManager {
    /// The currently running refresh task, if any.
    private var refreshTask: Task<TokenBundle, Error>?
    /// Cached token bundle loaded from the keychain.
    private var currentTokens: TokenBundle?

    init() {
        // Load any persisted tokens at initialization.
        currentTokens = try? KeychainService.load()
    }

    /// Returns a valid token bundle, refreshing if necessary.
    /// Throws if no tokens are available or if refresh fails.
    func validTokenBundle() async throws -> TokenBundle {
        // If a refresh is already in progress, await its result.
        if let task = refreshTask {
            return try await task.value
        }
        // No stored tokens means the user must log in.
        guard let tokens = currentTokens else {
            throw AuthError.noCredentials
        }
        // If not expired, return immediately.
        if !tokens.isExpired {
            return tokens
        }
        // Otherwise refresh.
        return try await refreshTokens()
    }

    /// Forces a refresh of the tokens regardless of expiration status.
    func refreshTokens() async throws -> TokenBundle {
        // If a refresh is already happening, await it.
        if let task = refreshTask {
            return try await task.value
        }
        // Ensure we have a refresh token.
        guard let tokens = currentTokens else {
            throw AuthError.noCredentials
        }
        // Create a new task to perform the refresh.
        let task = Task { () throws -> TokenBundle in
            defer { refreshTask = nil }
            // Build a request to your auth server’s token endpoint.
            // Replace api.example.com and path with your actual auth server and endpoint.
            var components = URLComponents()
            components.scheme = "https"
            components.host = "api.example.com" // change to your auth server
            components.path = "/oauth/token"
            var request = URLRequest(url: components.url!)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            let body: [String: String] = ["refresh_token": tokens.refreshToken]
            request.httpBody = try JSONEncoder().encode(body)
            // Perform the network call.
            let (data, response) = try await URLSession.shared.data(for: request)
            guard let httpResponse = response as? HTTPURLResponse,
                  (200..<300).contains(httpResponse.statusCode) else {
                throw AuthError.invalidCredentials
            }
            // Decode the new tokens.
            let newTokens = try JSONDecoder().decode(TokenBundle.self, from: data)
            // Persist and cache the tokens.
            try KeychainService.save(newTokens)
            currentTokens = newTokens
            return newTokens
        }
        // Store the in‑flight refresh task so concurrent callers reuse it.
        refreshTask = task
        return try await task.value
    }

    /// Clears stored tokens from memory and the keychain.
    func clearTokens() async throws {
        currentTokens = nil
        try KeychainService.delete()
    }
}

/// Errors thrown by `AuthManager`.
enum AuthError: Error {
    /// No tokens exist; the user must log in.
    case noCredentials
    /// Refresh failed or credentials are invalid.
    case invalidCredentials
}
Enter fullscreen mode Exit fullscreen mode

This actor lazily loads tokens from the keychain at initialization.
When a valid token is requested, it either returns the cached token (if it hasn’t expired) or refreshes it by calling server’s refresh token endpoint.
The refresh process is protected by a single Task: if multiple calls to validTokenBundle() happen concurrently, they will all await the same refresh task.
If no tokens are stored or the refresh fails, AuthManager throws an AuthError we can react to and logout the user.

Adding auth token to endpoints that require it

We will update our Endpoint enum to have a variable indicating whether this endpoint requires authentication or not.

var requiresAuthentication: Bool {
    switch self {
    case .secureEndpoint: return true
    default : return false
    }
}
Enter fullscreen mode Exit fullscreen mode

We will add the auth token when needed inside the NetworkClient func send<T: APIRequest>:

if request.endpoint.requiresAuthentication {
    let tokens = try await authManager.validTokenBundle()
    urlRequest.setValue("Bearer \(tokens.accessToken)", forHTTPHeaderField: "Authorization")
}
Enter fullscreen mode Exit fullscreen mode

We will need an AuthManager object in our client:

final class NetworkClient {
    private let authManager: AuthManager

    init(authManager: AuthManager = AuthManager()) {
        self.authManager = authManager
    }

    func send<T: APIRequest>(_ request: T, allowRetry: Bool = true) async throws -> T.Response {
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Our send function requires a variable to know if a retry is allowed:

 func send<T: APIRequest>(_ request: T, allowRetry: Bool = true) async throws -> T.Response
Enter fullscreen mode Exit fullscreen mode

Lastly let's check for auth errors and react accordingly:

if httpResponse.statusCode == 401 {
    guard allowRetry else {
        throw NetworkError.unauthorized // A new error type 
    }

    do {
        _ = try await authManager.refreshTokens()
        return try await send(request, allowRetry: false)
    } catch {
        // refresh failed -> force re-auth path
        // optionally: try? await authManager.clearTokens()
        throw error
    }
}
Enter fullscreen mode Exit fullscreen mode

We are done!

You can checkout the full project on GitHub.

Top comments (0)