Why OAuth Matters for iOS Apps

Modern iOS users expect a seamless login experience without repeatedly typing passwords. OAuth (Open Authorization) enables this by letting users authenticate via trusted providers such as Google, Facebook, or Apple. Beyond convenience, OAuth significantly improves security because your app never handles the user’s raw password. Instead, it receives a scoped, revocable token that limits what your app can do on the user’s behalf.

For iOS developers, implementing OAuth correctly means balancing user experience with robust security. Apple’s ecosystem provides powerful tools like ASWebAuthenticationSession and the AuthenticationServices framework, but understanding the flow end to end is critical. This article walks you through building a production-ready OAuth login system in Swift, covering everything from initial registration to token refresh and secure storage.

Understanding the OAuth 2.0 Flow in iOS

OAuth 2.0 defines several grant types. For mobile apps, the Authorization Code Flow with PKCE (Proof Key for Code Exchange) is the gold standard. It prevents authorization code interception even if the redirect URI is compromised. Here’s how it works step by step:

  1. The app generates a cryptographically random code_verifier and its derived code_challenge.
  2. The user is redirected to the provider’s login page (e.g., Google).
  3. After authentication, the provider redirects back to your app with a short-lived authorization code.
  4. The app sends the code, along with the original code_verifier, to the provider’s token endpoint.
  5. The provider verifies the challenge and returns an access token, refresh token, and id token (if OpenID Connect).

By using PKCE, your app eliminates the need for a client secret on the device—something that could be extracted from a binary. This is why Apple’s ASWebAuthenticationSession and libraries like AppAuth-iOS natively support PKCE.

Setting Up Your OAuth Provider

Before writing a single line of Swift, you must register your app with the OAuth provider. This process varies slightly between providers, but the core steps are the same.

1. Create a Project in the Developer Console

For Google, go to the Google Cloud Console. For Facebook, visit the Facebook Developers site. For Apple, use the Apple Developer portal and enable “Sign in with Apple.”

2. Configure Your Redirect URI

The redirect URI must be a custom URL scheme that matches your app’s bundle identifier. For example, com.example.myapp://oauth/callback. Many providers also accept universal links, but URL schemes are simpler for initial development. Ensure the scheme is registered in your Xcode project under URL Types.

3. Obtain Client ID and (Optional) Client Secret

For providers like Google that require a client secret for the token exchange, you must store it securely. With PKCE, the secret is not needed on the client side, but some providers still mandate it. If you must use a secret, never hardcode it into the binary. Instead, retrieve it from your backend when the user authenticates.

Implementing the OAuth Flow in Swift

You have two main paths: use Apple’s built-in ASWebAuthenticationSession or integrate the open-source AppAuth-iOS library. Both are secure, but AppAuth offers more control for complex flows (e.g., multiple providers, token customization). We’ll cover both approaches.

Using ASWebAuthenticationSession (Native Approach)

Available since iOS 12, ASWebAuthenticationSession opens a browser window with the provider’s login page. It handles cookies and shared sessions automatically. Here’s a minimal implementation for Google OAuth:

import AuthenticationServices

class OAuthService: NSObject, ASWebAuthenticationPresentationContextProviding {
    private let clientID = "YOUR_CLIENT_ID"
    private let redirectURI = "com.example.myapp://oauth/callback"
    private let authURL = "https://accounts.google.com/o/oauth2/v2/auth"
    private let tokenURL = "https://oauth2.googleapis.com/token"
    
    func startLogin() {
        // Generate PKCE code verifier and challenge
        let codeVerifier = generateCodeVerifier()
        let codeChallenge = generateCodeChallenge(for: codeVerifier)
        
        guard var components = URLComponents(string: authURL) else { return }
        components.queryItems = [
            URLQueryItem(name: "client_id", value: clientID),
            URLQueryItem(name: "redirect_uri", value: redirectURI),
            URLQueryItem(name: "response_type", value: "code"),
            URLQueryItem(name: "scope", value: "openid email profile"),
            URLQueryItem(name: "code_challenge_method", value: "S256"),
            URLQueryItem(name: "code_challenge", value: codeChallenge)
        ]
        
        guard let authURL = components.url else { return }
        
        let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: "com.example.myapp") { callbackURL, error in
            guard error == nil, let callbackURL = callbackURL else { return }
            // Extract authorization code from callback URL
            guard let code = self.extractCode(from: callbackURL) else { return }
            // Exchange code for tokens (see next section)
            self.exchangeCodeForTokens(code: code, codeVerifier: codeVerifier)
        }
        session.presentationContextProvider = self
        session.prefersEphemeralWebBrowserSession = true // For private sign-in
        session.start()
    }
    
    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        return UIApplication.shared.windows.first!
    }
}

This code generates PKCE values, constructs the authorization URL, and presents the session. Note that you must implement the extractCode and exchangeCodeForTokens methods (shown later).

Using AppAuth-iOS (Library Approach)

AppAuth-iOS abstracts away many details and provides built-in PKCE support. Add it via CocoaPods: pod 'AppAuth', '~> 1.6'. Then import AppAuth.

import AppAuth

class OAuthService {
    let authConfig = OIDServiceConfiguration(
        authorizationEndpoint: URL(string: "https://accounts.google.com/o/oauth2/v2/auth")!,
        tokenEndpoint: URL(string: "https://oauth2.googleapis.com/token")!
    )
    let clientID = "YOUR_CLIENT_ID"
    let redirectURI = URL(string: "com.example.myapp://oauth/callback")!
    
    func startLogin(presenting viewController: UIViewController) {
        let request = OIDAuthorizationRequest(
            configuration: authConfig,
            clientId: clientID,
            clientSecret: nil, // Not needed with PKCE
            scopes: ["openid", "email", "profile"],
            redirectURL: redirectURI,
            responseType: OIDResponseTypeCode,
            additionalParameters: nil
        )
        
        let appDelegate = UIApplication.shared.delegate as! AppDelegate
        let currentSession = OIDAuthorizationService.present(request, presenting: viewController) { authorizationResponse, error in
            if let code = authorizationResponse?.authorizationCode {
                self.exchangeCodeForTokens(authResponse: authorizationResponse)
            }
        }
        appDelegate.currentAuthorizationFlow = currentSession
    }
    
    func exchangeCodeForTokens(authResponse: OIDAuthorizationResponse?) {
        guard let authResponse = authResponse else { return }
        let tokenRequest = authResponse.tokenExchangeRequest()
        OIDAuthorizationService.perform(tokenRequest!) { tokenResponse, error in
            // Handle tokenResponse.accessToken, refreshToken, idToken
            // Save to Keychain
        }
    }
}

AppAuth automatically performs PKCE if the server supports it. The library also handles token exchange and refresh with minimal code. However, you still need to save tokens securely.

Exchanging the Authorization Code for Tokens

Regardless of the authentication method, you must exchange the short-lived authorization code for access and refresh tokens. This is a POST request to the provider’s token endpoint. Below is a generic Swift implementation using URLSession:

func exchangeCodeForTokens(code: String, codeVerifier: String) {
    guard let url = URL(string: tokenURL) else { return }
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    
    let params = [
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": redirectURI,
        "client_id": clientID,
        "code_verifier": codeVerifier
    ]
    request.httpBody = params.percentEncoded()
    request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
    
    URLSession.shared.dataTask(with: request) { data, response, error in
        guard let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
        let accessToken = json["access_token"] as? String
        let refreshToken = json["refresh_token"] as? String
        let expiresIn = json["expires_in"] as? Int
        // Store securely
        TokenStorage.save(accessToken: accessToken, refreshToken: refreshToken)
    }.resume()
}

Note: The percentEncoded() method converts the dictionary to a URL-encoded string. You can implement it using URLQueryItem components.

Secure Token Storage with Keychain

Storing tokens in UserDefaults is a common rookie mistake. On iOS, use the Keychain Services API. Apple provides the SecItemAdd and SecItemCopyMatching functions. For simplicity, use a wrapper class:

class TokenStorage {
    static let service = "com.example.myapp.tokens"
    
    static func save(accessToken: String, refreshToken: String?) {
        let accessData = Data(accessToken.utf8)
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: "accessToken",
            kSecValueData as String: accessData
        ]
        SecItemDelete(query as CFDictionary) // Remove old
        SecItemAdd(query as CFDictionary, nil)
        
        if let refreshToken = refreshToken {
            let refreshData = Data(refreshToken.utf8)
            var refreshQuery = query
            refreshQuery[kSecAttrAccount as String] = "refreshToken"
            refreshQuery[kSecValueData as String] = refreshData
            SecItemDelete(refreshQuery as CFDictionary)
            SecItemAdd(refreshQuery as CFDictionary, nil)
        }
    }
    
    static func getAccessToken() -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: "accessToken",
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        guard status == errSecSuccess, let data = item as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }
}

For enhanced security, consider using Access Control Lists with kSecAttrAccessibleWhenUnlockedThisDeviceOnly to prevent backup of tokens.

Token Refresh and Session Management

Access tokens typically expire within an hour. A robust login system automatically refreshes tokens before they expire, so the user isn’t logged out unexpectedly. Implement a refresh method:

func refreshAccessToken(completion: @escaping (String?) -> Void) {
    guard let refreshToken = TokenStorage.getRefreshToken() else {
        completion(nil)
        return
    }
    
    guard let url = URL(string: tokenURL) else { return }
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    let params = [
        "grant_type": "refresh_token",
        "refresh_token": refreshToken,
        "client_id": clientID
    ]
    request.httpBody = params.percentEncoded()
    request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
    
    URLSession.shared.dataTask(with: request) { data, _, error in
        guard let data = data,
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let newAccessToken = json["access_token"] as? String else {
            // Refresh failed – user must re-authenticate
            completion(nil)
            return
        }
        let newRefreshToken = json["refresh_token"] as? String ?? refreshToken
        TokenStorage.save(accessToken: newAccessToken, refreshToken: newRefreshToken)
        completion(newAccessToken)
    }.resume()
}

Integrate this refresh logic into your networking layer. If an API request returns a 401, attempt a refresh before showing an error.

Security Best Practices

Beyond token storage, several practices harden your OAuth implementation:

  • Always use HTTPS: All communication with OAuth providers and your own backend must be over TLS. Certificate pinning adds an extra layer.
  • Validate the redirect URI: Ensure the callback URL that arrives matches exactly what you registered. Malicious apps can hijack custom schemes if you’re not strict.
  • Use ephemeral sessions: When presenting an authentication session via ASWebAuthenticationSession, set prefersEphemeralWebBrowserSession to true. This prevents cookies from being shared with Safari.
  • Limit scopes: Request only the minimum data your app needs (e.g., email instead of full profile). This reduces the risk if a token is leaked.
  • Rotate refresh tokens: Some providers (like Apple) rotate refresh tokens each time they are used. Handle new refresh tokens by updating Keychain immediately.
  • Monitor logs: Log authentication events silently (no personally identifiable information) to detect unusual patterns. Use Crashlytics or custom analytics.
  • Implement logout properly: Clear tokens from Keychain and revoke the refresh token via the provider’s revocation endpoint if supported.

Handling Common Errors

OAuth flows can fail in many ways. Your app should gracefully handle:

  • User cancels: The ASWebAuthenticationSession callback will have an error of type ASWebAuthenticationSessionError.canceledLogin. Show a friendly message.
  • Network errors: Retry with exponential backoff.
  • Token expiration during API call: Queue the request, refresh the token, then retry.
  • Invalid grant: The authorization code may have been used already or expired. Direct the user to re-authenticate.

Build a dedicated error handler that interprets provider-specific error responses (e.g., invalid_grant, unauthorized_client).

Integrating with Sign in with Apple

Apple’s own OAuth provider requires special handling. Use the ASAuthorizationAppleIDProvider from the AuthenticationServices framework. It follows a slightly different flow but uses the same underlying OAuth 2.0. The user’s Apple ID token includes a user identifier that persists across logins, plus an optional email and name. Apple does not provide a refresh token in the traditional sense; instead, you use the identityToken to create a session on your backend.

Conclusion

Building a secure OAuth login system in an iOS app is more than just pasting SDK code. It requires understanding the authorization flow, implementing PKCE correctly, securing tokens in the Keychain, and handling token refresh seamlessly. By following the guidelines and code examples in this article, you can create a login experience that is both user-friendly and hardened against common attacks.

Remember that security is an ongoing process. Keep your dependencies updated, monitor for new threats, and always test with real devices and simulated edge cases. With a solid OAuth foundation, your users can sign in with confidence—and you can sleep better knowing their data is protected.