Mobile auth token handling — source audit
TL;DR: Mobile apps hold access tokens and refresh tokens. Source-audit risks: tokens in plain storage, refresh tokens with no rotation, tokens shared across user accounts on the same device, tokens leaked through deep links/logs/clipboard, biometric-bound tokens that aren’t actually bound, and tokens that survive uninstall via Keychain accessibility. Companion to mobile-client-storage-source-audit and android-keystore-and-crypto-audit / ios-keychain-and-secure-enclave-audit.
What “good” looks like
- Access tokens — short-lived (≤ 1h), held in memory; persisted only if the app needs offline auth.
- Refresh tokens — single-use (rotated on every refresh), Keystore/Keychain-bound, biometric-gated if the threat model requires.
- Both stored in hardware-backed storage, wiped on logout, never logged, never copied to clipboard.
Where to look
1
2
3
4
5
6
7
8
# Storage entry points
grep -rn 'token\|Token\|jwt\|accessToken\|refreshToken\|bearerToken' src/ -i
# Networking interceptors
grep -rn 'Interceptor\|RequestInterceptor\|URLProtocol\|adaptForRequest\|adapt(_:for:completion:)' .
# Auth flows
grep -rn 'login\|signIn\|signOut\|logout\|refresh' src/ -i
Bug class 1 — refresh token stored badly
The most common finding:
1
prefs.edit().putString("refresh_token", token).apply() // BAD
Fix: Keystore-encrypted prefs or EncryptedSharedPreferences. See android-keystore-and-crypto-audit.
Bug class 2 — refresh token not rotated
Refresh tokens should be single-use; the IdP issues a fresh one on each /token call. Apps that re-use the same refresh token across logins violate OAuth best practice and turn token theft into long-term access.
Audit the refresh flow:
1
2
3
val newAccess = api.refresh(currentRefresh)
saveAccess(newAccess.access_token)
saveRefresh(newAccess.refresh_token) // must overwrite the old one
Missing saveRefresh is a finding.
Bug class 3 — tokens leaked through logs
1
grep -rn 'Log\.[diwev]\|println\|NSLog\|os_log\|print(' . | grep -i 'token\|password\|secret'
Log.d("AUTH", "got token $token") ships in release if logging isn’t stripped. Verify ProGuard rules / R8 strip android.util.Log.d/v/i calls in release.
1
grep -rn 'assumenosideeffects' proguard-rules.pro
On iOS, search for os_log with %@ formatting of tokens; the system log persists across reboots.
Bug class 4 — tokens in URL parameters
1
val url = "https://api.example.com/me?token=$token"
URLs end up in:
- Crash reports.
- WebView history.
- Analytics logs.
- Referrer headers to third parties.
Always send tokens in Authorization headers, never query strings.
Bug class 5 — tokens shared across users on the same device
If the app supports multiple accounts, tokens for account A must not be readable when account B is active. Mistake patterns:
- One
prefsfile storing both accounts’ tokens; the active account is selected by a flag. - Keychain items keyed only by
kSecAttrServicewithoutkSecAttrAccount, so the lookup returns either user’s token.
Audit:
- Tokens keyed by user ID.
SecItemCopyMatchingqueries includekSecAttrAccount.
Bug class 6 — tokens that survive uninstall
iOS Keychain items with kSecAttrAccessible = Always or AfterFirstUnlock survive app uninstall on some iOS versions. A user uninstalls a banking app, reinstalls — and the old token is still valid until it expires.
Fix: on first launch, wipe any pre-existing Keychain items belonging to the app.
1
2
let q: [String: Any] = [kSecClass as String: kSecClassGenericPassword]
SecItemDelete(q as CFDictionary)
Bug class 7 — biometric “binding” that isn’t binding
App shows a BiometricPrompt / Face ID prompt. On success, reads the token from Keychain and uses it. The bug: the Keychain item is not bound to the biometric — the read succeeded because the device was unlocked. A malicious process running while the device is unlocked also reads it.
Fix: bind the cipher (Android) or use kSecAccessControl with .biometryCurrentSet (iOS). See android-keystore-and-crypto-audit / ios-keychain-and-secure-enclave-audit.
Bug class 8 — token in clipboard
1
grep -rn 'ClipboardManager\|setPrimaryClip\|UIPasteboard\.general\.string' .
OAuth flows that “copy the code to paste in the app” — flag, since clipboard is system-wide. Use deep-link-back patterns instead.
Bug class 9 — refresh flow race
If multiple HTTP requests happen near token expiry, each spawns its own refresh and the rotation races. Surface bugs:
- The IdP invalidates the older refresh token; some in-flight requests use a now-invalid access token; user is signed out.
- Token race uses an old refresh after rotation; replay attack window opens.
Audit refresh interceptors for a mutex / single-flight pattern:
1
2
private val refreshMutex = Mutex()
suspend fun refresh() = refreshMutex.withLock { ... }
Bug class 10 — JWT validation client-side only
A client-side check of an access token’s expiry or signature is not a security control. The bug is treating it as one.
1
if (jwt.isValid()) sendAuthorizedRequest() // BAD: trust on the client
The backend must validate. The client’s only legitimate use of expiry is “should I refresh proactively?” — never “should I trust this token?”.
Source-audit checklist
- Refresh tokens in Keystore/Keychain, never in SharedPreferences/UserDefaults.
- Refresh rotation: every refresh saves the new refresh token.
- No tokens in logs (verify R8/strip rules).
- No tokens in URL parameters.
- Per-user keying for multi-account apps.
- Wipe Keychain/Keystore on first launch to clear post-uninstall residue.
- Biometric binding actually binds the crypto operation.
- No token-in-clipboard flows.
- Single-flight refresh.
- Server is the only authoritative validator.