Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 039 / 100

Authentication in iOS apps: patterns, pitfalls and fixes

Secure sign-in flows.

Expona Admin9 min read

Authentication in iOS apps: patterns, pitfalls and fixes

User authentication is one of the first things we get wrong in iOS apps, and it often costs us dearly when it reaches production. We've all seen tokens stored in UserDefaults, refresh logic scattered across view controllers, or forgotten Sign in with Apple implementations that break user trust. This guide walks you through battle-tested patterns for secure sign-in flows, from leveraging system-level APIs to managing token lifecycles properly.

Sign in with Apple — Required if you offer other social logins

If your iOS app offers any third-party authentication method—Google, Facebook, GitHub—you're required to offer Sign in with Apple as well. This isn't just compliance; it's genuinely the best user experience. Apple handles the heavy lifting: credential management, two-factor authentication, and privacy are all baked in.

Let's implement a basic Sign in with Apple flow:

swift
import AuthenticationServices

class AuthViewController: UIViewController, ASAuthorizationControllerDelegate {
    @IBAction func signInWithApplePressed(_ sender: UIButton) {
        let request = ASAuthorizationAppleIDProvider().createRequest()
        request.requestedScopes = [.fullName, .email]
        
        let controller = ASAuthorizationController(authorizationRequests: [request])
        controller.delegate = self
        controller.presentationContextProvider = self
        controller.performRequests()
    }
    
    func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithAuthorization authorization: ASAuthorization
    ) {
        guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential else {
            return
        }
        
        let identityToken = credential.identityToken,
            authCode = credential.authorizationCode
        
        // Send identityToken and authCode to your backend
        // Backend validates with Apple, issues your own tokens
        sendToBackend(identityToken: identityToken, authCode: authCode)
    }
    
    func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithError error: Error
    ) {
        print("Sign in with Apple failed: \(error)")
    }
}

extension AuthViewController: ASAuthorizationControllerPresentationContextProviding {
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        return view.window ?? ASPresentationAnchor()
    }
}

The key here: never trust the identityToken on the client alone. Your backend must validate it with Apple's servers and return your own access and refresh tokens. The authCode is a one-time use code your backend can exchange for tokens; it's valid for about 10 minutes, so send it immediately.

One gotcha: on first sign-in, Apple provides the user's name and email. On subsequent sign-ins, you get neither—only the user identifier. Store that first response, or you'll lose the user's name on the next session.

Passkeys — Password-free sign-in with AuthenticationServices

Passkeys are the future. They eliminate passwords entirely while remaining more secure than SMS 2FA. iOS 16+ makes them almost trivial to implement through AuthenticationServices.

There are two flows: registration and authentication. For registration, we create a new passkey:

swift
import AuthenticationServices

func registerWithPasskey(username: String) async {
    let challenge = UUID().uuidString.data(using: .utf8)!
    
    let registrationRequest = ASAuthorizationPlatformPublicKeyCredentialProvider()
        .createCredentialRegistrationRequest(
            challenge: challenge,
            name: username,
            userID: UUID().uuidString.data(using: .utf8)!
        )
    
    let controller = ASAuthorizationController(authorizationRequests: [registrationRequest])
    controller.delegate = self
    controller.presentationContextProvider = self
    
    do {
        let result = try await controller.performRequests(options: .preferImmediatelyAvailableCredentials)
        if let credential = result.first?.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
            let attestationObject = credential.attestationObject,
                clientDataJSON = credential.clientDataJSON
            
            // Send attestationObject and clientDataJSON to backend for validation
            try await backendRegisterPasskey(
                username: username,
                attestation: attestationObject,
                clientData: clientDataJSON
            )
        }
    } catch {
        print("Passkey registration failed: \(error)")
    }
}

For authentication, we request an existing passkey:

swift
func authenticateWithPasskey() async {
    let challenge = UUID().uuidString.data(using: .utf8)!
    
    let authenticationRequest = ASAuthorizationPlatformPublicKeyCredentialProvider()
        .createCredentialAssertionRequest(challenge: challenge)
    
    let controller = ASAuthorizationController(authorizationRequests: [authenticationRequest])
    controller.delegate = self
    controller.presentationContextProvider = self
    
    do {
        let result = try await controller.performRequests(options: .preferImmediatelyAvailableCredentials)
        if let credential = result.first?.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion {
            let signature = credential.signature,
                clientDataJSON = credential.clientDataJSON
            
            try await backendAuthenticatePasskey(
                signature: signature,
                clientData: clientDataJSON
            )
        }
    } catch {
        print("Passkey authentication failed: \(error)")
    }
}

Your backend must handle the cryptographic validation—verifying the attestation format, checking the signature against the stored public key, and confirming the challenge. Libraries like webauthn-rs (Rust) or python-fido2 handle this complexity.

The win: users get a passwordless, phishing-resistant flow. The requirement: your backend must speak WebAuthn properly. If you're using a third-party auth service like Auth0 or Firebase, many now support passkeys natively.

Store tokens in Keychain — Never in UserDefaults

This is where we see the most damage. UserDefaults is not encrypted by default; it's a plist file on disk readable by anyone with device access (including apps with sufficient sandbox bypasses). Tokens belong in Keychain, which uses hardware-backed encryption on modern iPhones.

Wrap Keychain access in a simple service:

swift
import Security

class KeychainService {
    enum KeychainError: Error {
        case save, retrieve, delete
    }
    
    static let shared = KeychainService()
    
    func save(token: String, forKey key: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: token.data(using: .utf8)!
        ]
        
        SecItemDelete(query as CFDictionary)
        
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.save
        }
    }
    
    func retrieve(forKey key: String) throws -> String {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true
        ]
        
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        
        guard status == errSecSuccess,
              let data = result as? Data,
              let token = String(data: data, encoding: .utf8) else {
            throw KeychainError.retrieve
        }
        return token
    }
    
    func delete(forKey key: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]
        
        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.delete
        }
    }
}

Use it like this:

swift
try KeychainService.shared.save(token: accessToken, forKey: "access_token")
try KeychainService.shared.save(token: refreshToken, forKey: "refresh_token")

let storedToken = try KeychainService.shared.retrieve(forKey: "access_token")

On logout, delete the tokens completely. Never leave them lingering in Keychain—they can be extracted if the device is compromised later.

Refresh tokens — Refresh before expiry in your API client

Access tokens expire by design. Refresh tokens let you get a new access token without re-authenticating. The pattern: intercept every API request, check token expiry, refresh if needed, then proceed.

Build this into your HTTP client layer:

swift
class APIClient {
    let baseURL: URL
    let keychain = KeychainService.shared
    
    func request(
        _ endpoint: String,
        method: String = "GET"
    ) async throws -> T {
        try await ensureValidToken()
        
        var urlRequest = URLRequest(url: baseURL.appendingPathComponent(endpoint))
        urlRequest.httpMethod = method
        
        if let accessToken = try? keychain.retrieve(forKey: "access_token") {
            urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
        }
        
        let (data, response) = try await URLSession.shared.data(for: urlRequest)
        
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw APIError.statusCode
        }
        
        return try JSONDecoder().decode(T.self, from: data)
    }
    
    private func ensureValidToken() async throws {
        let expiryKey = "token_expiry"
        
        if let expiryString = try? keychain.retrieve(forKey: expiryKey),
           let expiry = Double(expiryString),
           expiry > Date().timeIntervalSince1970 {
            return // Token still valid
        }
        
        // Token expired, refresh it
        try await refreshAccessToken()
    }
    
    private func refreshAccessToken() async throws {
        guard let refreshToken = try? keychain.retrieve(forKey: "refresh_token") else {
            throw AuthError.noRefreshToken
        }
        
        var request = URLRequest(url: baseURL.appendingPathComponent("/auth/refresh"))
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode(["refresh_token": refreshToken])
        
        let (data, response) = try await URLSession.shared.data(for: request)
        
        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            // Refresh failed; clear tokens and force re-authentication
            try keychain.delete(forKey: "access_token")
            try keychain.delete(forKey: "refresh_token")
            throw AuthError.refreshFailed
        }
        
        let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
        try keychain.save(token: decoded.accessToken, forKey: "access_token")
        try keychain.save(token: decoded.refreshToken, forKey: "refresh_token")
        
        let expiry = Date().addingTimeInterval(decoded.expiresIn).timeIntervalSince1970
        try keychain.save(token: String(expiry), forKey: "token_expiry")
    }
}

struct TokenResponse: Codable {
    let accessToken: String
    let refreshToken: String
    let expiresIn: TimeInterval
}

By handling refresh in the API client, every request is automatically covered. No view controller needs to care about token lifecycle.

Common mistakes

  • Storing tokens in UserDefaults: We've covered this. It's not encrypted. Use Keychain.
  • Not handling token expiry: If your token expires mid-request and you don't refresh, the user gets a login screen in the middle of using the app. Refresh proactively.
  • Forgetting the Sign in with Apple requirement: App Review will reject you if you offer other social logins without Apple. Check your submission checklist.
  • Trusting client-side JWT validation: A token is just a string to the client. Always validate signatures and claims on the backend.
  • Not clearing tokens on logout: Tokens should be deleted from Keychain immediately when the user logs out. If you skip this, the next user to pick up the device might still have access.
  • Hardcoding client secrets: Never. Client secrets and API keys belong on the backend or in a secure config server, never in your app binary.

Key takeaways

  • Sign in with Apple is required if you offer any other social login; implement it using ASAuthorizationController and validate tokens on the backend.
  • Passkeys eliminate passwords and phishing risk, but require WebAuthn validation on your backend.
  • Always store tokens in Keychain, never UserDefaults. Build a simple wrapper service to keep Keychain access clean.
  • Refresh tokens before expiry by checking expiry in your HTTP client layer; refresh failures should trigger re-authentication.
  • Validate all credentials on the backend; the client is never a trust boundary.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles