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 Is Different from Web OAuth
Mobile OAuth presents unique challenges absent from web-based flows. Users cannot inspect the browser address bar to verify the authorization server’s URL, there is no server-side secret storage available, and the flow requires app switching between the browser and the native application. Because of these constraints, the Authorization Code Flow with PKCE (Proof Key for Code Exchange) is the only recommended OAuth flow for mobile apps — the Implicit Flow is deprecated and must never be used. PKCE replaces the static client secret with a dynamically-generated code verifier that the authorization server uses to validate the token request, preventing authorization code interception attacks even if the redirect URI is compromised.
A critical architectural decision is whether to use the system browser or an embedded
WebView. The system browser (or a Chrome Custom Tab / ASWebAuthenticationSession) shares
cookies with the user’s default browser, providing a seamless login experience for existing
sessions, and runs in a separate process, which protects the app from malicious sites loaded in
the web view. A WebView gives the app more UI control but cannot persist authentication state
across apps and does not share the system credential store. Universal Links (iOS) and App Links
(Android) make the redirect back to the app seamless; without them the OS may show a
confirmation dialog. Once tokens are obtained, biometric authentication (Face ID, fingerprint)
can protect stored refresh tokens, and platform-native APIs such as ASWebAuthenticationSession on iOS and Chrome
Custom Tabs on Android provide the gold-standard implementation.
Mobile OAuth Architecture
Token Flow for Mobile Apps
The Authorization Code Flow with PKCE is the canonical OAuth flow for mobile applications, and
the diagram below walks through its six stages. Every step matters because each one closes a
specific security gap. The flow begins by generating the PKCE pair — a random code_verifier and its
hashed code_challenge — which is stored on the device. The app then hands control to the system browser,
which navigates to the authorization server, displays the provider’s login screen, and collects
user consent. Only after the user authenticates does the browser redirect back to the app with
a single-use authorization code, delivered via a custom URL scheme or an App/Universal Link.
The app exchanges that code for tokens by presenting the code together with the original code_verifier,
proving it is the same client that started the flow.
The final stage — secure token storage — is where many implementations go wrong. Access tokens, refresh tokens, and ID tokens must live in the platform’s hardened storage: the iOS Keychain or Android EncryptedSharedPreferences. The diagram also makes an important architectural point: the native app never sees the user’s password, and no client secret is ever embedded in the binary. The secret is replaced entirely by the PKCE verifier, which is why PKCE is mandatory rather than optional in mobile contexts:
┌─────────────────────────────────────────────────────────────────┐
│ 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
All of the flow complexity shown above is handled for you by AppAuth, the reference OpenID
Connect library maintained by the OpenID Foundation. AppAuth is the standard choice because it
implements the entire specification — PKCE generation, token exchange, refresh, and token
revocation — against any conforming provider, including Google, GitHub, Microsoft, and Auth0.
The Podfile below declares the two dependencies the iOS app needs: the core AppAuth pod and,
optionally, the auth0-oidc adapter if you also target Auth0.
Because AppAuth is provider-agnostic, you configure it per identity provider rather than forking the login code. The trade-off is worth naming explicitly: a generic library is slightly more verbose than a provider SDK like Google Sign-In, but it keeps your code portable across providers and, more importantly, keeps all credentials and tokens under the same consistent storage and refresh machinery. For teams that expect to support multiple sign-in providers, AppAuth avoids the N-way duplication that provider SDKs inevitably create:
// Podfile
pod 'AppAuth', '~> 1.6'
pod 'auth0-oidc', '~> 2.0' // Optional: Auth0 support
AppDelegate Configuration
The AppDelegate is the entry point for the entire OAuth lifecycle on iOS. This class has three
responsibilities: restore a previous session at launch, receive the redirect when the system
browser finishes authentication, and persist the resulting authorization state. The restoreAuthState() call in
didFinishLaunchingWithOptions is what makes sessions survive app restarts — AppAuth serializes its OIDAuthState object, and
restoring it means the user is still logged in the next time the app launches.
The application(_:open:options:) method is where the redirect lands. iOS delivers the callback URL here after the
browser completes the flow, and the method parses it back into an OIDAuthorizationResponse. Two implementation
details are easy to miss. First, NSKeyedArchiver with requiringSecureCoding: true is used to serialize the state before storing it
in the Keychain — this guards against deserialization attacks on a security-sensitive object.
Second, after saving, the code posts a NotificationCenter notification so any view controller waiting on the
login result can react, decoupling the networking/plumbing layer from the UI:
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
AppAuth separates the immutable pieces of configuration from the flow itself, and the OAuthConfig
struct below centralizes them in one place. It holds the client IDs, redirect URIs,
authorization and token endpoints, and requested scopes for each provider the app supports.
Note the redirect URIs: they use a custom URL scheme (com.example.app), and it is critical that these match
the registered redirect URIs in the provider’s developer console exactly — a single character
difference makes the callback silently fail.
The struct also pre-builds the provider-agnostic pieces. OIDServiceConfiguration pairs each provider’s
authorization endpoint with its token endpoint, and the googleAuthRequest shows the interesting detail: clientSecret
is nil and responseType is OIDResponseTypeCode. This is the PKCE-driven, code-flow shape discussed earlier — no secret,
just a code exchange, with code_challenge_method: S256 passed as an additional parameter. The lazily-initialized
properties ensure these objects are created only once per launch and reused, which keeps the
codebase DRY and the runtime cost negligible:
// 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
Every token your app stores is only as secure as its storage, and on iOS that means the
Keychain. The KeychainHelper below is a thin wrapper over the Security framework’s C API with the three
operations the app needs: save, load, and delete. The save operation first deletes any existing
item for the same service/account pair, then inserts the new data — this upsert pattern
prevents duplicate entries from accumulating across re-logins.
The most security-relevant line is the access control attribute: kSecAttrAccessibleWhenUnlockedThisDeviceOnly. This restricts the item
to the current device and makes it inaccessible when the device is locked, which is the correct
default for tokens. The ThisDeviceOnly suffix is what prevents the item from being migrated to a new
device in a backup restore, which would silently ship tokens off the original device. The
wrapper deliberately returns raw Bool/Data? results rather than throwing — the OAuth layer treats
Keychain failures as fatal-but-logged events and can force a re-login:
// 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
The login screen ties everything together. The LoginViewController below is deliberately minimal on the UI side
— a single sign-in button — and puts its complexity in the flow orchestration. When the button
is tapped, signInWithGoogle generates the PKCE verifier and challenge, persists the verifier to UserDefaults (it must
survive the switch to the browser and back), and constructs the OIDAuthorizationRequest. AppAuth then presents the
provider’s login UI via ASWebAuthenticationSession under the hood, and the completion closure receives the
authorization response.
That closure starts the token exchange. Note the flow split: the PKCE verifier is stored before
the browser opens and read back after it returns, because the exchange happens in a different
code path. exchangeCodeForTokens builds a tokenExchangeRequest that includes the verifier, performs it, and routes the response
into storeTokens, which persists the access and refresh tokens to the Keychain and the expiry to UserDefaults.
Only after all of that does the app navigate to the main screen. The state machine is worth
internalizing: verifier generation → browser authorization → code receipt → token exchange →
secure storage → UI transition, with each step guarded by its own error handling:
// 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
Android follows the same architecture with AppAuth’s Java port. The build.gradle block below pulls in
three libraries with distinct roles. net.openid:appauth is the OAuth engine itself — the equivalent of the iOS
AppAuth pod. The AndroidX Browser library provides Chrome Custom Tabs, which is the recommended
way to host the authorization UI: a full system browser session that shares cookies with Chrome
but runs in a controlled, app-started tab. The security-crypto library supplies EncryptedSharedPreferences and MasterKey,
which encrypt token storage with keys held in the Android Keystore.
The versions shown are the ones to pin deliberately rather than let drift. AppAuth 0.11.x is a
stable, widely deployed release, and the security-crypto 1.1.0-alpha line — despite the alpha
label — is what most production apps use because the stable 1.0.x has known issues with backup
and modern keystore configurations. Treating these as explicit version pins in a version
catalog or libs.versions.toml is strongly recommended so the whole team builds against identical 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
The Android equivalent of iOS’s custom URL scheme is an intent filter. The manifest below
registers MainActivity for the VIEW action with the custom scheme com.example.app, which makes the system route OAuth
callbacks of that scheme to the activity. The BROWSABLE category is the crucial line — it tells the
system this activity can be opened from a browser, which is exactly how the authorization
server’s redirect reaches your app.
There is a security nuance behind this simple declaration. Custom schemes are less secure than
App Links because any app can register the same scheme; on older Android versions the OS shows
a chooser, and on some devices a malicious app could intercept the callback. That is why the
guide pairs this manifest entry with App Links later — an https:// link with autoVerify="true" gives you verified
ownership and bypasses the chooser entirely. If you must use a custom scheme, make it
sufficiently unique (reverse-DNS style) and validate the received URL’s host and path before
processing it:
<!-- 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
Android’s token storage story is stronger than it used to be, and the TokenManager below is the
recommended pattern. It builds a MasterKey with AES256-GCM and uses it to create an EncryptedSharedPreferences instance that
encrypts both keys and values — the data at rest is protected by keys stored in the
hardware-backed Android Keystore. Every accessor in the class reads through this encrypted
store, so tokens never touch plain SharedPreferences.
The class exposes exactly the four operations the rest of the app needs: saveTokens, getAccessToken, getRefreshToken, and
clearTokens, plus isTokenValid for expiry checks. Keeping the API this small is deliberate — it prevents
accidental misuse by making the storage mechanics invisible. Note that clearTokens() wipes the entire
secure prefs namespace, which is important for logout; leaving refresh tokens behind after
logout is a classic bug that lets a deleted session be resurrected via the token endpoint. For
even stronger protection, this class is the natural place to add biometric-gated access before
returning the refresh token:
// 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
The OAuthActivity is the Android counterpart of the iOS login view controller, and it has an extra duty:
because activities can be recreated by the system, it must handle the callback both in onCreate and
in onNewIntent. The handleOAuthCallback method reads either an AuthorizationResponse or an AuthorizationException from the incoming intent and branches on
which one arrived. If the response is present, it builds a token exchange request, attaching
the PKCE code_verifier that was stashed in SharedPreferences when the flow started.
The companion object holds the static entry point, startOAuth. It generates the PKCE pair, persists
the verifier, constructs the AuthorizationRequest against Google’s endpoints, and opens the flow through a
Chrome Custom Tab. The naming and structure mirror the iOS side exactly — the same six-stage
flow, the same verifier handoff, the same secure storage endpoint — which is precisely the
point of using the same AppAuth family on both platforms: the mobile OAuth architecture is
platform-agnostic, and only the thin platform glue differs. Keeping the two implementations
structurally parallel makes them far easier to audit and maintain:
// 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
Access tokens are deliberately short-lived, which means every mobile app needs a refresh path.
The Swift TokenManager.refreshAccessToken below constructs an OIDTokenRequest with grantType: OIDGrantType.refreshToken, passing the stored refresh token and the
service configuration captured earlier. AppAuth’s performTokenRequest handles the network call and callback
threading; on success the new access token is written back through saveTokens and surfaced via the
completion handler.
The authenticatedRequest function shows how refresh integrates into everyday network calls. Before every API
request it checks isTokenValid() — which internally compares the stored expiry against the current time —
and, if expired, synchronously awaits a refresh before attaching the Authorization header. This pattern
gives every request automatic, transparent re-authentication: callers never think about token
lifecycle, and a single central location owns refresh, expiry checks, and retry. The one
trade-off is a latency spike when a token expires exactly at request time; a common refinement
is to refresh slightly before actual expiry to avoid the synchronous stall:
// 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)
}
The Android refresh path is the mirror image. The suspend fun refreshToken below runs the token request on a
background dispatcher, builds a TokenRequest with the refresh token, and calls performTokenRequest through AppAuth. Note
the asymmetry with iOS: on a refresh failure the code calls tokenManager.clearTokens() and returns null — a deliberate
choice that forces the user to re-authenticate rather than letting the app limp along with a
corrupted session.
The error handling in this snippet is worth copying. It distinguishes the happy path (return the new access token), a hard failure (clear state and signal re-login), and swallows nothing — every branch is explicit. In a real app you would wrap this in a coroutine and have the networking layer observe the null return to trigger a re-login screen. Combined with the iOS version, the pattern is: refresh silently when possible, fail loudly with state cleanup when not, and centralize the decision in one manager:
// 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
Custom URL schemes work, but Universal Links are the more secure and more polished alternative
on iOS. The entitlements file below declares applinks:example.com, which registers your domain’s App Links with
the system. When the authorization server redirects to an https://example.com/oauth/callback URL, iOS verifies ownership
against a file hosted at that domain and opens your app directly — no confirmation dialog, and
no risk of another app hijacking the scheme.
The continue handler in AppDelegate receives the navigation. It checks activityType == NSUserActivityTypeBrowsingWeb, extracts the URL, and —
if the path matches the OAuth callback — hands it to the same callback processing used by
custom schemes. This unification matters: whether the redirect arrives via a scheme or a
universal link, it converges on one handleOAuthCallback code path, so the token exchange logic is never
duplicated. Universal Links are especially important now because some providers are deprecating
custom-scheme callbacks in favor of HTTPS redirects:
// 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
Android’s equivalent is App Links, shown in the manifest below. The key difference from a
custom scheme intent filter is android:autoVerify="true" combined with an https scheme and a real host. At install time
Android contacts the domain, reads the Digital Asset Links file it must host there, and — if it
matches your app’s signing certificate — marks the link as verified. Verified links open your
app directly; unverified ones fall back to a chooser.
The android:pathPrefix="/oauth/callback" scopes the association so only that path routes to the app, keeping the rest of your
domain in the browser. The security property worth internalizing: because verification is
cryptographic (the domain file is signed by a certificate you control), no other app can claim
your redirect URL. This is strictly safer than a custom scheme and is the direction the
platform is pushing — modern providers increasingly require HTTPS redirect URIs, which makes
App Links a practical necessity rather than an option:
<!-- 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
Security in mobile OAuth is a checklist discipline, and the two blocks below capture the
minimum bar for production. The iOS list centers on four pillars. Storage: tokens belong in the
Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly, never in UserDefaults. Protocol: PKCE is mandatory, and the code verifier must come
from a cryptographically secure random source. Transport: certificate pinning on the token
endpoint defends against a compromised CA or a misconfigured proxy. And redirect hygiene: the
redirect URI must be validated exactly, since a fuzzy match is an attack surface.
The list also flags two practices that are easy to overlook. Restricting Keychain access to “when unlocked, this device only” reduces the window in which a locked device can leak tokens. And short-lived access tokens combined with automatic refresh mean that even a successfully stolen token is useless within minutes. Taken together, these rules form the same threat model as any server-side OAuth deployment — it is the storage and transport constraints that differ on mobile, not the fundamental security posture:
// 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"
]
The Android checklist mirrors the iOS one with platform-specific tools. Encrypted storage means
EncryptedSharedPreferences backed by a MasterKey in the Keystore, and the code verifier must be generated with SecureRandom rather
than Math.random. Obfuscation is called out specifically: ProGuard/R8 must keep the AppAuth classes,
because stripping or renaming them breaks the reflection-based callbacks AppAuth relies on to
restore state across the browser switch.
Two Android-specific threats get explicit treatment. Custom tabs are the sanctioned browser host, and the app should verify the tab’s identity rather than blindly launching whatever handles the intent — on rooted or compromised devices a malicious browser could observe the flow. And App Links must be verified through Digital Asset Links, which is the counterpart to iOS Universal Links. The consistent theme across both platforms: never trust the client environment, encrypt everything at rest, prove your redirects, and make every token short-lived and refreshable:
// 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