Mobile API Security: OAuth 2.1 PKCE Flow and Token Storage Best Practices
If your mobile app still handles OAuth with an embedded WebView and drops tokens into SharedPreferences, you are one malicious app away from a breach. OAuth 2.1 formalizes what security engineers have preached for years: PKCE is non-negotiable, and token storage is a hardware problem.
Mobile API security fails differently than web. Apps are public clients — distributed binaries anyone can decompile. There is no client secret to hide. The device itself is hostile territory: jailbroken iPhones, rooted Android devices, clipboard sniffers, backup extractors, and shoulder surfers all live in your threat model. As a hardware analyst, I see authentication as a chain from silicon to server, and OAuth 2.1 is the first spec that treats it that way.
1. The Mobile Threat Model Is Different
Unlike confidential web servers, mobile apps cannot protect a static secret. Tools like Frida, Objection, and APKTool can dump strings and hook crypto calls in minutes. OAuth 2.0’s original Implicit flow and naive Authorization Code flow assumed a trusted client — a bad fit for mobile. The result was a decade of authorization code interception attacks.
On Android, a malicious app can register the same custom scheme (e.g., myapp://callback) and race to capture the authorization code. On iOS, an insecure CFBundleURLSchemes can be hijacked if Universal Links are not verified. Add leaked app links, compromised keyboards, and iTunes/ADB backups that copy app containers, and plain-text token storage becomes an existential risk.
This is why modern mobile security leans on hardware roots of trust. Apple’s Secure Enclave and Android’s Titan M2 / StrongBox provide isolated key storage that even a kernel compromise cannot easily extract. When we discuss token storage, we are really discussing which hardware boundary protects your refresh_token. That decision matters as much as your TLS configuration — similar to how UWB digital key isolation relies on hardware-backed attestation to prevent relay attacks.
2. OAuth 2.1: What Changed and Why PKCE Is Now Mandatory
OAuth 2.1 is not a new protocol; it is a consolidation draft that removes insecure options from OAuth 2.0. The major changes for mobile developers:
| Feature | 5G NR | Wi-Fi 7 | Bluetooth 5.4 |
|---|---|---|---|
| Peak Throughput | 20 Gbps | 46 Gbps | 2 Mbps |
| Latency | 1–4 ms | 1–2 ms | 3–6 ms |
| Range | Several km | 30–50 m | 100–400 m |
| Power Draw | High | Medium | Ultra-low |
| Primary Use | Cellular data | Local networking | Peripherals/IoT |
- Implicit flow removed.
response_type=tokenis gone. Never return tokens directly in the redirect fragment. - Password grant removed. Resource Owner Password Credentials flow is deprecated. No more collecting raw passwords in-app.
- PKCE required for all Authorization Code flows. RFC 7636 is no longer optional. S256 challenge method is mandatory.
- Exact redirect URI matching. No more wildcard or loose prefix matching.
- Refresh tokens for public clients require sender-constraining or rotation.
PKCE (Proof Key for Code Exchange, pronounced “pixy”) solves code interception. The client generates a high-entropy code_verifier and sends its SHA256 hash (code_challenge) at authorization time. Later, when exchanging the code for tokens, it proves possession of the original verifier. An attacker who intercepts the code cannot exchange it without the verifier, which never leaves the device’s secure memory.
From a silicon perspective, PKCE generation should use the platform CSPRNG — SecRandomCopyBytes on iOS and SecureRandom on Android — not Math.random(). On the A18 Pro’s Secure Enclave and its TRNG, entropy quality is hardware-accelerated, but you still must request 32+ bytes (43–128 characters after base64url encoding).
3. The PKCE Flow Dissected: From Verifier to Token Exchange
Here is the hardened flow for a native app using a system browser:
Figure 1: OAuth 2.1 Authorization Code flow with PKCE — the system browser is the trust boundary; the verifier binds the code to the original device.
// 1. Generate code_verifier (43-128 chars, base64url, no padding)
func generateVerifier() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
return Data(bytes).base64URLEncoded() // 43 chars
}
// 2. Derive S256 code_challenge
func challenge(for verifier: String) -> String {
let hash = SHA256.hash(data: Data(verifier.utf8))
return Data(hash).base64URLEncoded()
}
// 3. Launch system browser — never WKWebView
let session = ASWebAuthenticationSession(
url: authURL(with: challenge), callbackURLScheme: "com.jones.app") { url, _ in
// Extract code, then POST /token with verifier
}
// Use AppAuth or manual Custom Tabs intent
val verifier = generateCodeVerifier() // SecureRandom, 32 bytes
val challenge = verifier.sha256().base64UrlNoPadding()
val authUri = Uri.parse("https://auth.example.com/authorize").buildUpon()
.appendQueryParameter("response_type", "code")
.appendQueryParameter("code_challenge", challenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("redirect_uri", "https://app.example.com/oauth/callback")
.appendQueryParameter("state", state) // CSRF protection, store & verify
.build()
CustomTabsIntent.Builder().build().launchUrl(context, authUri)
Critical details often missed: state must be cryptographically random and validated; redirect_uri must be an HTTPS App Link / Universal Link verified via assetlinks.json / apple-app-site-association, not a bare custom scheme. And the token exchange must happen server-side or via a confidential gateway if you need a client secret — never embed it in the app binary. If you are building companion hardware features like integrated charging authentication circuits, the same verified-link principle applies to prevent spoofed power negotiations.
4. Token Storage: This Is a Hardware Problem
Receiving tokens is easy; keeping them secret is the real engineering. Access tokens are bearer tokens — anyone who steals one can call your API until it expires. Refresh tokens are long-lived and even more sensitive. Storage choice dictates your breach impact.
| Storage Mechanism | Hardware Backed? | Extraction Resistance | Use For |
|---|---|---|---|
| iOS Keychain + Secure Enclave kSecAttrAccessibleWhenUnlockedThisDeviceOnly |
Yes — SEP | Survives app reinstall? No. Backup extractable? No if ThisDeviceOnly | Refresh & access tokens, verifier |
| Android StrongBox KeyStore EncryptedSharedPreferences + MasterKey |
Yes — Titan M2 | Key extraction requires hardware attack. File dump useless without TEE | Tokens, DPoP keys |
| Android TEE KeyStore (no StrongBox) | Partial — TEE | Better than software, weaker than StrongBox against physical attacks | Fallback when StrongBox unavailable |
| UserDefaults / SharedPreferences / Files | No | Trivial via backup, root, or debugger. No encryption at rest. | Never for tokens |
| In-Memory Only | Ephemeral | Lost on restart, but safe from at-rest extraction. Needs re-auth. | High-risk apps (banking) for access token |
On iOS, configure Keychain with kSecAttrAccessControl set to .biometryAny or .devicePasscode if you want refresh on biometric. This ties decryption to the Secure Enclave’s biometric gate — similar to how ray-tracing cores isolate BVH memory, the enclave isolates key material from the application processor.
let access = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryAny, nil)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.jones.api",
kSecAttrAccount as String: "refresh_token",
kSecValueData as String: tokenData,
kSecAttrAccessControl as String: access
]
SecItemAdd(query as CFDictionary, nil)
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.setRequestStrongBoxBacked(true) // Falls back to TEE if unavailable
.build()
val prefs = EncryptedSharedPreferences.create(
context, "secure_tokens", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
prefs.edit().putString("refresh_token", token).apply()
Other hardening rules: set access_token expiry to 5–15 minutes and use rotating refresh tokens (one-time use, with reuse detection to revoke the family on theft). Prefer HttpOnly, Secure, SameSite cookies for web hybrids, but for native, bind tokens to the device with DPoP (Demonstrating Proof-of-Possession) or mTLS where your IdP supports it. DPoP signs each request with a device-held private key that never leaves StrongBox/SEP — even a stolen bearer token is useless without the proof.
5. Hardening Checklist: From Silicon to Server
- System browser only:
ASWebAuthenticationSession/ Custom Tabs. Block embedded WebViews at the IdP. - Verified redirects: HTTPS Universal Links / App Links with exact match. No custom-scheme fallback in production.
- Short-lived access, rotating refresh: 5–15 min access, 30-day max refresh with automatic rotation and revocation on reuse.
- Hardware-backed storage: Keychain
ThisDeviceOnly+ StrongBox/TEE. Wipe tokens on logout and biometric change. - App attestation: Play Integrity API / App Attest to bind tokens to genuine app instances.
- Certificate pinning with care: Pin leaf + backup pin, with remote config to rotate. Avoid bricking apps on cert renewal.
Finally, instrument your auth flows. Log token exchange failures, PKCE mismatches, and refresh reuse events centrally. On device, avoid logging tokens or verifiers — even in debug builds — and disable screenshots on auth screens (FLAG_SECURE on Android). If you expose APIs to accessories or companion chips, treat their authentication lifecycle like your secure car-key stack: short-lived, hardware-bound, and revocable.
Conclusion
OAuth 2.1 does not reinvent authentication; it removes the excuses for insecure mobile implementations. PKCE is now baseline, not a bonus. But PKCE without secure token storage is theater. The real security boundary is the hardware — Secure Enclave on iOS, StrongBox/TEE on Android — and the discipline to keep tokens out of world-readable storage. Build for that boundary, rotate fast, and verify every redirect. Your API will thank you, and so will your users when the next clipboard-stealing malware makes the news and your tokens stay put.
Frequently Asked Questions
Mobile apps are public clients that cannot keep a client secret. Without PKCE, an attacker intercepting the authorization code via a hijacked redirect can exchange it for tokens. PKCE binds the code to a verifier only the legitimate app holds, making intercepted codes worthless. OAuth 2.1 mandates S256 PKCE for all Authorization Code flows.
Use iOS Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly and optional biometric access control backed by Secure Enclave. On Android, use EncryptedSharedPreferences with a MasterKey backed by AndroidKeyStore and request StrongBox. Never use UserDefaults, unencrypted SharedPreferences, or plain files.
5 to 15 minutes for access tokens. Pair them with rotating refresh tokens that are single-use with reuse detection; revoke the entire token family if a reused token is seen. Set an absolute refresh lifetime of 24 hours for high-risk apps up to 30 days for consumer apps, requiring re-authentication after.
No. Use ASWebAuthenticationSession or SFSafariViewController on iOS and Chrome Custom Tabs on Android. System browsers provide SSO, protect credentials from the app, support verified app links, and are required by providers like Google. Embedded WebViews are vulnerable to credential phishing and are explicitly discouraged in OAuth 2.1.