Introduction
Implementing OAuth in mobile apps requires different considerations than web applications. Mobile apps face unique challenges including secure token storage, browser integration, and deep link handling. This guide covers best practices for implementing OAuth in iOS and Android applications.
Mobile OAuth Architecture
Token Flow for Mobile Apps
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Mobile OAuth Flow โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ 1. App initiates OAuth request โ
โ - Generate PKCE code_verifier โ
โ - Generate code_challenge โ
โ - Store code_verifier securely โ
โ โ โ
โ โผ โ
โ 2. Open system browser (or custom tab) โ
โ - Navigate to authorization URL โ
โ - Pass client_id, redirect_uri, code_challenge โ
โ โ โ
โ โผ โ
โ 3. User authenticates in browser โ
โ - Provider shows login screen โ
โ - User enters credentials โ
โ - User authorizes the app โ
โ โ โ
โ โผ โ
โ 4. Browser redirects to app โ
โ - Uses custom URL scheme or universal link โ
โ - App receives authorization code โ
โ โ โ
โ โผ โ
โ 5. App exchanges code for tokens โ
โ - Send code + code_verifier to token endpoint โ
โ - Receive access_token, refresh_token โ
โ โ โ
โ โผ โ
โ 6. Store tokens securely โ
โ - iOS: Keychain โ
โ - Android: EncryptedSharedPreferences โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
iOS Implementation with AppAuth
Setup
// Podfile
pod 'AppAuth', '~> 1.6'
pod 'auth0-oidc', '~> 2.0' // Optional: Auth0 support
AppDelegate Configuration
import AppAuth
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var authState: OIDAuthState?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Restore previous auth state
restoreAuthState()
return true
}
// Handle OAuth callback via custom URL scheme
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey : Any] = [:]
) -> Bool {
// Check if this is an OAuth callback
if let authResponse = OIDAuthorizationResponse(
fromURL: url,
configuration: OAuthConfig.shared.authConfiguration
) {
authState?.handleAuthorizationCode(
authResponse.authorizationCode,
state: authResponse.state
) { authState, error in
if let error = error {
print("Auth error: \(error.localizedDescription)")
return
}
// Save auth state
self.saveAuthState(authState!)
NotificationCenter.default.post(
name: .oauthCallbackReceived,
object: nil
)
}
return true
}
return false
}
// Store auth state
private func saveAuthState(_ authState: OIDAuthState) {
let data = try? NSKeyedArchiver.archivedData(
withRootObject: authState,
requiringSecureCoding: true
)
// Store in Keychain
KeychainHelper.save(
data: data!,
service: "com.example.app.authstate"
)
self.authState = authState
}
private func restoreAuthState() {
guard let data = KeychainHelper.load(
service: "com.example.app.authstate"
) else { return }
do {
authState = try NSKeyedUnarchiver.unarchivedObject(
ofClass: OIDAuthState.self,
from: data
)
} catch {
print("Failed to restore auth state: \(error)")
}
}
}
OAuth Configuration
// OAuthConfig.swift
import Foundation
import AppAuth
struct OAuthConfig {
static let shared = OAuthConfig()
// Google OAuth configuration
let googleClientId = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com"
let googleRedirectScheme = "com.example.app"
let googleRedirectUri = "com.example.app:/oauth2callback"
// GitHub OAuth configuration
let githubClientId = "YOUR_GITHUB_CLIENT_ID"
let githubRedirectScheme = "com.example.app"
let githubRedirectUri = "com.example.app://oauth/callback"
// Authorization endpoints
let googleAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth"
let githubAuthUrl = "https://github.com/login/oauth/authorize"
// Token endpoints
let googleTokenUrl = "https://oauth2.googleapis.com/token"
let githubTokenUrl = "https://github.com/login/oauth/access_token"
// Scopes
let googleScopes = ["openid", "email", "profile"]
let githubScopes = ["read:user", "user:email"]
lazy var googleConfiguration: OIDServiceConfiguration = {
OIDServiceConfiguration(
authorizationEndpoint: URL(string: googleAuthUrl)!,
tokenEndpoint: URL(string: googleTokenUrl)!
)
}()
lazy var githubConfiguration: OIDServiceConfiguration = {
OIDServiceConfiguration(
authorizationEndpoint: URL(string: githubAuthUrl)!,
tokenEndpoint: URL(string: githubTokenUrl)!
)
}()
lazy var googleAuthRequest: OIDAuthorizationRequest = {
OIDAuthorizationRequest(
configuration: googleConfiguration,
clientId: googleClientId,
clientSecret: nil,
scope: googleScopes.joined(separator: " "),
redirectUrl: URL(string: googleRedirectUri)!,
responseType: OIDResponseTypeCode,
additionalParameters: [
"code_challenge_method": "S256"
]
)
}()
}
Keychain Helper
// KeychainHelper.swift
import Foundation
import Security
class KeychainHelper {
static func save(data: Data, service: String, account: String = "default") -> Bool {
// Delete existing item
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
SecItemDelete(deleteQuery as CFDictionary)
// Add new item
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
static func load(service: String, account: String = "default") -> Data? {
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 result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
return result as? Data
}
static func delete(service: String, account: String = "default") -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
}
Login View Controller
// LoginViewController.swift
import UIKit
import AppAuth
class LoginViewController: UIViewController {
private let authButton: UIButton = {
let button = UIButton(type: .system)
button.setTitle("Sign in with Google", for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
button.backgroundColor = .white
button.layer.cornerRadius = 8
button.setTitleColor(.black, for: .normal)
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
private func setupUI() {
view.backgroundColor = UIColor(red: 0.07, green: 0.13, blue: 0.26, alpha: 1.0)
// Add subviews
view.addSubview(authButton)
// Layout
authButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
authButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
authButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
authButton.widthAnchor.constraint(equalToConstant: 240),
authButton.heightAnchor.constraint(equalToConstant: 50)
])
// Add actions
authButton.addTarget(self, action: #selector(signInWithGoogle), for: .touchUpInside)
}
@objc private func signInWithGoogle() {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
return
}
// Generate PKCE
let codeVerifier = generateCodeVerifier()
let codeChallenge = generateCodeChallenge(from: codeVerifier)
// Store verifier for token exchange
UserDefaults.standard.set(codeVerifier, forKey: "code_verifier")
// Create authorization request
var request = OIDAuthorizationRequest(
configuration: OAuthConfig.shared.googleConfiguration,
clientId: OAuthConfig.shared.googleClientId,
clientSecret: nil,
scope: OAuthConfig.shared.googleScopes.joined(separator: " "),
redirectUrl: URL(string: OAuthConfig.shared.googleRedirectUri)!,
responseType: OIDResponseTypeCode,
additionalParameters: [
"code_challenge": codeChallenge,
"code_challenge_method": "S256"
]
)
// Create auth presentation context
let presentingViewController = self
let authViewController = OIDAuthorizationService.present(
request,
presenting: presentingViewController
) { authResponse, error in
if let error = error {
print("Authorization error: \(error.localizedDescription)")
return
}
guard let authResponse = authResponse else { return }
// Exchange code for tokens
self.exchangeCodeForTokens(authResponse: authResponse)
}
}
private func exchangeCodeForTokens(authResponse: OIDAuthorizationResponse) {
let codeVerifier = UserDefaults.standard.string(forKey: "code_verifier")
guard let codeVerifier = codeVerifier else {
print("Code verifier not found")
return
}
// Create token request
let tokenRequest = authResponse.tokenExchangeRequest(
withClientId: OAuthConfig.shared.googleClientId,
codeVerifier: codeVerifier
)
// Perform token exchange
OIDAuthorizationService.perform(tokenRequest!) { tokenResponse, error in
if let error = error {
print("Token exchange error: \(error.localizedDescription)")
return
}
guard let tokenResponse = tokenResponse else { return }
// Store tokens
self.storeTokens(tokenResponse)
// Navigate to main screen
DispatchQueue.main.async {
self.navigateToMain()
}
}
}
private func storeTokens(_ response: OIDTokenResponse) {
// Access token
if let accessToken = response.accessToken {
KeychainHelper.save(
data: Data(accessToken.utf8),
service: "com.example.app",
account: "access_token"
)
}
// Refresh token
if let refreshToken = response.refreshToken {
KeychainHelper.save(
data: Data(refreshToken.utf8),
service: "com.example.app",
account: "refresh_token"
)
}
// Expiry
if let expiry = response.accessTokenExpirationDate {
UserDefaults.standard.set(expiry, forKey: "token_expiry")
}
}
private func navigateToMain() {
let mainVC = MainViewController()
let navController = UINavigationController(rootViewController: mainVC)
navController.modalPresentationStyle = .fullScreen
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
window.rootViewController = navController
window.makeKeyAndVisible()
}
}
// PKCE helpers
private func generateCodeVerifier() -> String {
var buffer = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
return Data(buffer).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func generateCodeChallenge(from verifier: String) -> String {
guard let data = verifier.data(using: .utf8) else { return "" }
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash)
}
return Data(hash).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
Android Implementation
Dependencies
// build.gradle
dependencies {
implementation 'net.openid:appauth:0.11.1'
implementation 'androidx.browser:browser:1.7.0'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
}
Android Manifest
<!-- AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.app">
<!-- Custom URL scheme for OAuth callback -->
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- OAuth callback handling -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.example.app" />
</intent-filter>
</activity>
</manifest>
Token Management
// TokenManager.kt
package com.example.app
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import net.openid.appauth.TokenRequest
import net.openid.appauth.TokenResponse
class TokenManager(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val securePrefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
companion object {
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_EXPIRY = "token_expiry"
private const val KEY_ID_TOKEN = "id_token"
}
fun saveTokens(response: TokenResponse) {
response.accessToken?.let { token ->
securePrefs.edit()
.putString(KEY_ACCESS_TOKEN, token)
.apply()
}
response.refreshToken?.let { token ->
securePrefs.edit()
.putString(KEY_REFRESH_TOKEN, token)
.apply()
}
response.idToken?.let { token ->
securePrefs.edit()
.putString(KEY_ID_TOKEN, token)
.apply()
}
response.accessTokenExpirationTime?.let { expiry ->
securePrefs.edit()
.putLong(KEY_EXPIRY, expiry)
.apply()
}
}
fun getAccessToken(): String? {
return securePrefs.getString(KEY_ACCESS_TOKEN, null)
}
fun getRefreshToken(): String? {
return securePrefs.getString(KEY_REFRESH_TOKEN, null)
}
fun isTokenValid(): Boolean {
val expiry = securePrefs.getLong(KEY_EXPIRY, 0)
return System.currentTimeMillis() < expiry
}
fun clearTokens() {
securePrefs.edit().clear().apply()
}
}
OAuth Activity
// OAuthActivity.kt
package com.example.app
import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import net.openid.appauth.*
import androidx.browser.customtabs.CustomTabsIntent
class OAuthActivity : AppCompatActivity() {
private lateinit var authService: AuthorizationService
private lateinit var tokenManager: TokenManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
authService = AuthorizationService(this)
tokenManager = TokenManager(this)
handleOAuthCallback(intent)
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
intent?.let { handleOAuthCallback(it) }
}
private fun handleOAuthCallback(intent: Intent) {
val response = AuthorizationResponse.fromIntent(intent)
val error = AuthorizationException.fromIntent(intent)
when {
response != null -> {
exchangeCodeForTokens(response)
}
error != null -> {
Log.e("OAuth", "Authorization failed: ${error.error}")
finish()
}
private fun exchange }
}
CodeForTokens(response: AuthorizationResponse) {
val additionalParams = mutableMapOf<String, String>()
// Add PKCE code verifier
val codeVerifier = getSharedPreferences("oauth", MODE_PRIVATE)
.getString("code_verifier", null)
codeVerifier?.let {
additionalParams["code_verifier"] = it
}
val tokenRequest = response.createTokenExchangeRequest(additionalParams)
authService.performTokenRequest(tokenRequest) { response, error ->
when {
response != null -> {
tokenManager.saveTokens(response)
Log.d("OAuth", "Tokens saved successfully")
// Navigate to main screen
startActivity(Intent(this, MainActivity::class.java))
finish()
}
error != null -> {
Log.e("OAuth", "Token exchange failed: ${error.errorDescription}")
finish()
}
}
}
}
companion object {
// Start OAuth flow
fun startOAuth(context: android.content.Context) {
val authService = AuthorizationService(context)
// Generate PKCE
val codeVerifier = generateCodeVerifier()
val codeChallenge = generateCodeChallenge(codeVerifier)
// Save verifier for callback
context.getSharedPreferences("oauth", android.content.Context.MODE_PRIVATE)
.edit()
.putString("code_verifier", codeVerifier)
.apply()
// Build authorization request
val request = AuthorizationRequest.Builder(
AuthorizationServiceConfiguration(
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
Uri.parse("https://oauth2.googleapis.com/token")
),
"YOUR_CLIENT_ID.apps.googleusercontent.com",
ResponseTypeValues.CODE,
Uri.parse("com.example.app:/oauth2callback")
)
.setScope("openid email profile")
.setAdditionalParameters(
mapOf(
"code_challenge" to codeChallenge,
"code_challenge_method" to "S256"
)
)
.build()
// Open custom tab
val customTabsIntent = CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
authService.authorize(request) { intent, error ->
if (intent != null) {
customTabsIntent.launchUrl(context, intent.toUri(Intent.URI_INTENT_SCHEME))
} else {
Log.e("OAuth", "Failed to create authorization intent: ${error?.errorDescription}")
}
}
}
private fun generateCodeVerifier(): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
return (1..32)
.map { chars.random() }
.joinToString("")
}
private fun generateCodeChallenge(verifier: String): String {
val bytes = verifier.toByteArray()
val digest = java.security.MessageDigest.getInstance("SHA-256")
val hash = digest.digest(bytes)
return android.util.Base64.encodeToString(
hash,
android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE
).replace("=", "")
}
}
}
Token Refresh
// iOS Token Refresh
class TokenManager {
// ... existing code ...
func refreshAccessToken(completion: @escaping (Result<String, Error>) -> Void) {
guard let refreshToken = getRefreshToken() else {
completion(.failure(AuthError.noRefreshToken))
return
}
// Create refresh request
guard let config = authState?.authorizationServiceConfiguration else {
completion(.failure(AuthError.noConfiguration))
return
}
let request = OIDTokenRequest(
configuration: config,
grantType: OIDGrantType.refreshToken,
authorizationCode: nil,
clientId: OAuthConfig.shared.googleClientId,
clientSecret: nil,
redirectURL: nil,
scopes: nil,
refreshToken: refreshToken,
codeVerifier: nil,
additionalParameters: nil
)
authState?.performTokenRequest(request) { response, error in
if let error = error {
completion(.failure(error))
return
}
guard let accessToken = response?.accessToken else {
completion(.failure(AuthError.noAccessToken))
return
}
// Update stored tokens
self.saveTokens(response!)
completion(.success(accessToken))
}
}
}
// Using token manager with URLSession
func authenticatedRequest(_ request: URLRequest) async throws -> Data {
// Check if token is expired or close to expiring
if !tokenManager.isTokenValid() {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
tokenManager.refreshAccessToken { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
var authenticatedRequest = request
authenticatedRequest.setValue(
"Bearer \(tokenManager.getAccessToken())",
forHTTPHeaderField: "Authorization"
)
return try await URLSession.shared.data(for: authenticatedRequest)
}
// Android Token Refresh
suspend fun refreshToken(): String? {
val refreshToken = tokenManager.getRefreshToken() ?: return null
return withContext(Dispatchers.IO) {
val request = TokenRequest.Builder(
AuthorizationServiceConfiguration(
Uri.parse("https://oauth2.googleapis.com/token"),
Uri.parse("https://oauth2.googleapis.com/token")
),
"YOUR_CLIENT_ID.apps.googleusercontent.com"
)
.setGrantType(GrantTypeValues.REFRESH_TOKEN)
.setRefreshToken(refreshToken)
.build()
try {
val response = authService.performTokenRequest(request).await()
tokenManager.saveTokens(response)
response.accessToken
} catch (e: Exception) {
Log.e("Token", "Refresh failed: ${e.message}")
tokenManager.clearTokens()
null
}
}
}
Universal Links and App Links
iOS Universal Links
// Associated Domains (Entitlements)
{
"com.apple.developer.associated-domains": [
"oauth:example.com",
"applinks:example.com"
]
}
// Handle universal link in AppDelegate
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webPageURL {
// Handle OAuth callback via universal link
if url.path.contains("/oauth/callback") {
// Process OAuth response
handleOAuthCallback(url: url)
return true
}
}
return false
}
Android App Links
<!-- AndroidManifest.xml -->
<application>
<activity android:name=".MainActivity">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="example.com"
android:pathPrefix="/oauth/callback" />
</intent-filter>
</activity>
</application>
Security Best Practices for Mobile
// iOS Security Checklist
mobile_oauth_security = [
// Store tokens securely
"Use Keychain for token storage",
"Set appropriate access control (kSecAttrAccessibleWhenUnlockedThisDeviceOnly)",
// PKCE is mandatory
"Always use PKCE for authorization code exchange",
"Generate cryptographically random code verifier",
// Certificate pinning
"Implement TLS certificate pinning for token endpoints",
// Deep link security
"Validate redirect URI matches exactly",
"Avoid using data URLs for redirect URIs",
// Biometric authentication
"Consider adding biometric for sensitive operations",
// Token handling
"Use short-lived access tokens",
"Implement proper token refresh logic",
"Clear tokens on logout"
]
// Android Security Checklist
android_oauth_security = [
// Encrypted storage
"Use EncryptedSharedPreferences for tokens",
"Use MasterKey with AES256-GCM",
// PKCE
"Always use PKCE",
"Generate secure code verifier using SecureRandom",
// ProGuard/R8
"Obfuscate OAuth library code",
"Keep AppAuth classes",
// Custom tabs
"Use CustomTabsIntent for OAuth flow",
"Verify browser is not malicious",
// App Links
"Implement App Links for secure deep linking",
"Verify links in Digital Asset Links"
]
Conclusion
Mobile OAuth implementation requires:
- Use AppAuth libraries - Battle-tested implementations for iOS/Android
- Implement PKCE - Mandatory for mobile OAuth flows
- Secure token storage - Keychain (iOS), EncryptedSharedPreferences (Android)
- Handle deep links - Custom URL schemes or universal/app links
- Token management - Automatic refresh, proper invalidation
Following these patterns ensures secure, production-ready OAuth integration in mobile applications.
Resources
- AppAuth iOS Documentation
- AppAuth Android Documentation
- Google Identity for iOS
- Google Identity for Android
Comments