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()
}
}
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)
}
}
}
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
}
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
}
}
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")
}
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 {
...
}
}
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
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
}
}
We are done!
You can checkout the full project on GitHub.
Top comments (0)