Authentication & Security
OAuth, Keychain, Face ID, secure networking, and secret management.
Authentication & Security
Learning Objectives
By the end of this chapter, you will understand how to secure an iOS application against modern threats. You will build secure authentication flows using OAuth and OpenID Connect, store secrets safely in the Keychain, implement biometric authentication with Face ID, properly handle certificates and secure networking, and protect your app against unauthorized access, jailbroken devices, and common security pitfalls.
Prerequisites
You must understand networking fundamentals (URLSession, HTTP, REST, JSON), asynchronous programming (async/await), and basic iOS application lifecycle architecture. If you don't know how a basic network request works in Swift, review Part 9 — Networking.
Why Does This Exist?
Users entrust their most sensitive data to our applications: financial records, personal communications, and health information. Mobile devices are portable, easily lost or stolen, and constantly connect to untrusted networks. If an application stores passwords in plain text, transmits data over unencrypted connections, or fails to verify the server's identity, an attacker can compromise the user's data. Authentication and security mechanisms exist to mathematically and cryptographically prove identity, ensure data privacy, and protect against malicious actors in an inherently hostile environment.
The Problem Before the Solution
Before modern security standards, developers often sent usernames and passwords directly with every network request (Basic Authentication). They stored user credentials in standard preference files (like UserDefaults on iOS) because it was convenient. If an app needed to integrate with a third-party service (like Facebook), it asked the user for their Facebook password.
Why the Old Approach Breaks
Sending passwords with every request increases the surface area for interception. Storing passwords in UserDefaults means any process or malicious app with access to the device's filesystem can read them. Asking for passwords to third-party services means the third-party must trust your application not to steal the credentials or abuse permissions. This approach is highly coupled, fragile, and catastrophically insecure. It scales poorly and violates the fundamental principle of least privilege.
History
As web and mobile applications evolved, the industry moved away from sharing raw passwords toward using ephemeral, specialized tokens (OAuth 1.0, then OAuth 2.0). Apple introduced the Keychain to provide a hardware-backed, encrypted storage mechanism for small secrets, replacing insecure file-based storage. To reduce reliance on typed passwords, Apple introduced Touch ID and later Face ID, utilizing the Secure Enclave processor to keep biometric data mathematically isolated from the main operating system.
Mental Model
Think of your iOS application as a VIP club.
OAuth/Tokens: Instead of giving the bouncer your social security number every time you enter (Basic Auth), you show your ID once, and they give you a wristband (Access Token). You show the wristband to get drinks. When the wristband expires, you go back to the desk (Refresh Token) to get a new one.
Keychain: Instead of leaving your spare key under the doormat (UserDefaults), you put it in a titanium vault welded to the building's foundation, which only opens with a specific combination (Keychain).
Biometrics (Face ID): The vault manager doesn't remember your face. Instead, they take a mathematical hash of your facial structure. When you try to open the vault, they take a new hash and compare it. If it matches, the vault opens. The actual photo of your face never leaves the vault (Secure Enclave).
Now remove the analogy. Here is what Swift/iOS actually does: OAuth uses standard HTTP headers to pass JWTs. The Keychain uses AES encryption backed by dedicated hardware. Face ID uses the LocalAuthentication framework to request a binary yes/no from the Secure Enclave.
Internal Working
When you save a secret to the iOS Keychain, the OS doesn't just write a file. It encrypts the data using keys derived from the user's passcode and hardware-specific UID baked into the Secure Enclave. This means even if an attacker physically extracts the flash memory chip from the iPhone, they cannot decrypt the Keychain data without the hardware key and the user's passcode.
When you use Face ID, your app does not see the user's face. Your app calls `LAContext().evaluatePolicy()`. The OS pauses your app, takes over the screen, activates the TrueDepth camera, and queries the Secure Enclave. The Secure Enclave does the math, and simply returns a `true` or `false` (with an optional error) back to your app.
Visual Explanation
OAuth 2.0 Flow:
App (Client) Auth Server API (Resource)
| | |
|--- 1. Request Auth ----->| |
|<-- 2. Auth Grant --------| |
|--- 3. Send Grant ------->| |
|<-- 4. Access Token ------| |
| |
|--- 5. API Request + Access Token -------------->|
|<-- 6. Protected Data ---------------------------|
Syntax
Interacting with the Keychain using Swift requires using CoreFoundation C-APIs, which rely on specific dictionaries.
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "user@example.com",
kSecValueData as String: secretData
]
SecItemAdd(query as CFDictionary, nil)
Using LocalAuthentication for Face ID:
import LocalAuthentication
let context = LAContext()
var error: NSError?
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
// Attempt authentication
}
Tiny Example
A simple class to authenticate a user with Face ID or Touch ID.
import LocalAuthentication
class BiometricAuthenticator {
func authenticate() async throws -> Bool {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
return false // Biometrics not available
}
do {
return try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Log in to access your secure data."
)
} catch {
return false
}
}
}
Walkthrough
In the `BiometricAuthenticator` above, we first create an `LAContext`. This object mediates the interaction between our app and the Secure Enclave. We must first call `canEvaluatePolicy` to ensure the device actually has biometric hardware and that the user has enrolled in it. If they have, we call `evaluatePolicy` asynchronously. The OS will present the Face ID/Touch ID UI. The method suspends our Swift task until the user authenticates, fails, or cancels. The result is a simple boolean.
Break It
What happens if you store an API token in UserDefaults?
// INSECURE
UserDefaults.standard.set("my-secret-api-token-xyz", forKey: "apiToken")
If an attacker gains physical access to an unlocked device, or if they jailbreak it, they can plug it into a computer and extract the app's `.plist` files. Your secret API token is sitting there in plain text. Furthermore, UserDefaults is not encrypted when the device is unlocked.
Debug It
To see how easily UserDefaults is compromised on a simulator, you can find the `.plist` file on your Mac. Print `NSHomeDirectory()` in your app, navigate to that path in Finder, open `Library/Preferences/`, and open your app's bundle identifier plist. You will see your data in plain XML. To fix this, we must use the Keychain.
Mini Project (20-30 min)
Build a simple KeychainManager struct that can save(token: String, forAccount: String) and readToken(forAccount: String) -> String?. Write a simple SwiftUI view with a Textfield to save a token, and a button to read it back.
View Solution
import SwiftUI
import Security
struct KeychainManager {
static func save(token: String, forAccount account: String) {
let data = Data(token.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
static func readToken(forAccount account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data {
return String(data: data, encoding: .utf8)
}
return nil
}
}
struct MiniProjectView: View {
@State private var inputToken = ""
@State private var displayedToken = ""
var body: some View {
VStack(spacing: 20) {
TextField("Enter Token", text: $inputToken)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button("Save Token") {
KeychainManager.save(token: inputToken, forAccount: "userAccount")
}
Button("Read Token") {
displayedToken = KeychainManager.readToken(forAccount: "userAccount") ?? "Not found"
}
Text("Token in Keychain: \(displayedToken)")
}
.padding()
}
}
Bigger Project (1-2 hours)
Implement a thread-safe token refresher. When your app receives a `401`, it should attempt to refresh. If 5 network requests fail simultaneously, they should not trigger 5 refresh network calls. They should all wait for a single refresh call, and then retry automatically.
View Solution
import Foundation
actor AuthManager {
static let shared = AuthManager()
private var currentToken: String? = "old_token"
private var refreshTask: Task<String, Error>?
func getValidToken() async throws -> String {
if let refreshTask = refreshTask {
return try await refreshTask.value
}
return currentToken ?? ""
}
func refreshToken() async throws -> String {
if let refreshTask = refreshTask {
return try await refreshTask.value
}
let task = Task { () -> String in
defer { refreshTask = nil }
// Simulate network delay
try await Task.sleep(nanoseconds: 1_000_000_000)
let newToken = "new_token_\(UUID().uuidString)"
self.currentToken = newToken
return newToken
}
self.refreshTask = task
return try await task.value
}
}
class NetworkClient {
func performRequest() async throws {
let token = try await AuthManager.shared.getValidToken()
print("Using token: \(token)")
// Simulate a 401 scenario
let isUnauthorized = true
if isUnauthorized {
let newToken = try await AuthManager.shared.refreshToken()
print("Retrying with new token: \(newToken)")
}
}
}
Interview Questions
Easy: Why shouldn't you store passwords in UserDefaults?
UserDefaults is stored in plain text (plist) on the file system and is easily readable by anyone with access to the device backup or a jailbroken device. You should use the Keychain instead.
Medium: How does OAuth 2.0 improve security over Basic Authentication?
OAuth uses scoped, short-lived access tokens instead of sending the user's password with every request. If a token is intercepted, it can only be used for a limited time and limited scope, and the user's primary password remains secure.
Hard: How do you handle multiple simultaneous network requests when a token expires?
You must queue the requests. When the first `401` is received, suspend the other outgoing requests, use an actor or a serial queue to perform the token refresh exactly once, and then resume the queued requests with the new token.
Revision Sheet
- UserDefaults: Only for non-sensitive user preferences.
- Keychain: Encrypted, secure storage for passwords and tokens.
- Face ID: Uses `LocalAuthentication`. App gets a boolean, not the biometric data.
- OAuth: Access Token (short-lived) + Refresh Token (long-lived).
- Hardcoding: Never hardcode production secrets in source code. They can be extracted from the compiled binary via reverse engineering tools (like `strings`).
Connections
This chapter connects directly to Part 9 — Networking (using URLSession to send tokens) and Part 3 — Concurrency (using Actors to manage thread-safe token refreshing). It builds the foundation for building professional, production-ready apps that handle user data responsibly.