Mobile Security & Privacy — API Authentication

Mobile API Security: OAuth 2.1 PKCE Flow and Token Storage Best Practices

Marcus Chen, Mobile Hardware Analyst August 31, 2026 8 min read

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.

TL;DR: Use Authorization Code + PKCE with system browsers only. Store tokens in Keychain / AndroidKeyStore with hardware-backed keys (Secure Enclave / StrongBox). Keep access tokens to 5–15 min, rotate refresh tokens, and enforce sender-constraining where hardware allows.

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:

Mobile Connectivity Standards Comparison
Feature 5G NR Wi-Fi 7 Bluetooth 5.4
Peak Throughput20 Gbps46 Gbps2 Mbps
Latency1–4 ms1–2 ms3–6 ms
RangeSeveral km30–50 m100–400 m
Power DrawHighMediumUltra-low
Primary UseCellular dataLocal networkingPeripherals/IoT
  • Implicit flow removed. response_type=token is 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:

OAuth 2.1 PKCE Flow for Mobile Apps Block diagram showing Mobile App, System Browser, Authorization Server, and Resource Server with PKCE steps. MOBILE APP SYSTEM BROWSER AUTHORIZATION SERVER RESOURCE API KEYSTORE / KEYCHAIN code_verifier (32+ bytes) S256 code_challenge 1. CREATE VERIFIER CSPRNG + SHA256 4. EXCHANGE CODE code + verifier → tokens 6. STORE TOKENS Encrypted + Biometric 2 ASWebAuthenticationSession / Custom Tabs + code_challenge + state + nonce 3 User Authenticates & Consents Returns authorization_code via verified redirect (https + app link) authorization_code → verifier proof 5. API CALL Authorization: Bearer access_token Validates JWT & scopes System browser isolates credentials • Verifier never leaves device • Code useless without verifier

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.

Swift — PKCE Generation (CSPRNG + S256)iOS 15+
// 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
}
Kotlin — Authorization Request with Custom TabsAndroidX
// 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.

iOS — Keychain Store with Biometric Bound KeySecurity.framework
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)
Android — EncryptedSharedPreferences + StrongBoxJetpack Security
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

✓
Production Checklist
  • 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

Why is PKCE mandatory in OAuth 2.1 for mobile apps?

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.

Where should I store access and refresh tokens on iOS and Android?

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.

How long should mobile access tokens live?

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.

Can I use WebView for OAuth login on mobile?

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.

References & Standards: IEEE 802.11 · Bluetooth SIG