Designing Secure Auth Flows for Mobile Applications
Architecture for secure authentication flows in mobile apps, covering OAuth 2.0 with PKCE, token management, biometric auth, and session security.
Auth flows on mobile have unique attack surfaces: token storage on potentially rooted devices, deep link interception, clipboard sniffing, and screen recording. This post covers how to design auth flows that account for these threats while maintaining a smooth user experience.
Related: How I'd Design a Mobile Configuration System at Scale.
Context
Mobile authentication involves obtaining, storing, refreshing, and revoking tokens on a device the developer does not control. The device may be rooted, the network may be intercepted, and the user may share the device with others. The auth system must be resilient to all of these conditions.
Problem
Design an authentication system that:
- Authenticates users securely using industry-standard protocols
- Stores tokens safely on the device
- Refreshes tokens transparently without user interaction
- Handles multi-device sessions and forced logout
- Supports biometric authentication as a secondary factor
Constraints
| Constraint | Detail |
|---|---|
| Token storage | Keystore/Keychain available, but not all devices support hardware-backed keys |
| Network | All auth traffic must use TLS 1.2+; certificate pinning recommended |
| Session duration | Access tokens: 15-30 minutes. Refresh tokens: 30-90 days |
| UX | Token refresh must be invisible; re-authentication should be rare |
| Compliance | Must support forced logout for account compromise scenarios |
Design
OAuth 2.0 with PKCE
PKCE (Proof Key for Code Exchange) prevents authorization code interception, which is critical on mobile where custom URI schemes can be registered by malicious apps.
See also: Event Tracking System Design for Android Applications.
class AuthFlowManager(
private val authConfig: AuthConfig,
private val tokenStore: SecureTokenStore
) {
fun initiateLogin(): Uri {
val codeVerifier = generateCodeVerifier() // 43-128 char random string
val codeChallenge = sha256Base64Url(codeVerifier)
// Store verifier for later exchange
tokenStore.saveCodeVerifier(codeVerifier)
return Uri.parse(authConfig.authorizationEndpoint).buildUpon()
.appendQueryParameter("client_id", authConfig.clientId)
.appendQueryParameter("response_type", "code")
.appendQueryParameter("redirect_uri", authConfig.redirectUri)
.appendQueryParameter("scope", authConfig.scopes)
.appendQueryParameter("code_challenge", codeChallenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("state", generateState())
.build()
}
suspend fun handleRedirect(uri: Uri): AuthResult {
val code = uri.getQueryParameter("code")
?: return AuthResult.Error("No authorization code")
val state = uri.getQueryParameter("state")
if (!validateState(state)) {
return AuthResult.Error("Invalid state parameter")
}
val codeVerifier = tokenStore.getCodeVerifier()
?: return AuthResult.Error("No code verifier found")
return exchangeCodeForTokens(code, codeVerifier)
}
private suspend fun exchangeCodeForTokens(
code: String,
codeVerifier: String
): AuthResult {
val response = authApi.exchangeCode(
grantType = "authorization_code",
code = code,
redirectUri = authConfig.redirectUri,
clientId = authConfig.clientId,
codeVerifier = codeVerifier
)
tokenStore.saveTokens(response.accessToken, response.refreshToken)
return AuthResult.Success(response.accessToken)
}
}Secure Token Storage
class SecureTokenStore(private val context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val encryptedPrefs = EncryptedSharedPreferences.create(
context,
"auth_tokens",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveTokens(accessToken: String, refreshToken: String) {
encryptedPrefs.edit()
.putString("access_token", accessToken)
.putString("refresh_token", refreshToken)
.putLong("access_token_expiry", System.currentTimeMillis() + ACCESS_TOKEN_TTL)
.apply()
}
fun getAccessToken(): String? {
val expiry = encryptedPrefs.getLong("access_token_expiry", 0)
if (System.currentTimeMillis() > expiry) return null // Expired
return encryptedPrefs.getString("access_token", null)
}
fun getRefreshToken(): String? =
encryptedPrefs.getString("refresh_token", null)
fun clearAll() {
encryptedPrefs.edit().clear().apply()
}
}Token Refresh
class TokenRefreshAuthenticator(
private val tokenStore: SecureTokenStore,
private val authApi: AuthApi
) : Authenticator {
private val refreshLock = Mutex()
override fun authenticate(route: Route?, response: Response): Request? {
if (response.code != 401) return null
if (responseCount(response) >= 2) return null // Prevent infinite loops
return runBlocking {
refreshLock.withLock {
// Check if another thread already refreshed
val currentToken = tokenStore.getAccessToken()
val requestToken = response.request.header("Authorization")
?.removePrefix("Bearer ")
if (currentToken != null && currentToken != requestToken) {
// Token was already refreshed by another request
return@runBlocking response.request.newBuilder()
.header("Authorization", "Bearer $currentToken")
.build()
}
// Perform refresh
val refreshToken = tokenStore.getRefreshToken()
?: return@runBlocking null // No refresh token, force re-login
try {
val tokens = authApi.refreshToken(
grantType = "refresh_token",
refreshToken = refreshToken,
clientId = AuthConfig.CLIENT_ID
)
tokenStore.saveTokens(tokens.accessToken, tokens.refreshToken)
response.request.newBuilder()
.header("Authorization", "Bearer ${tokens.accessToken}")
.build()
} catch (e: Exception) {
tokenStore.clearAll()
null // Force re-login
}
}
}
}
}Biometric Authentication
Biometric auth gates access to the refresh token, not the auth flow itself. This provides a fast re-authentication without sending credentials over the network.
class BiometricTokenAccess(
private val context: Context,
private val tokenStore: SecureTokenStore
) {
fun authenticateAndGetToken(
activity: FragmentActivity,
callback: (String?) -> Unit
) {
val biometricPrompt = BiometricPrompt(
activity,
ContextCompat.getMainExecutor(context),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: AuthenticationResult) {
callback(tokenStore.getAccessToken())
}
override fun onAuthenticationFailed() {
callback(null)
}
}
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Verify identity")
.setNegativeButtonText("Use password")
.setAllowedAuthenticators(BIOMETRIC_STRONG)
.build()
biometricPrompt.authenticate(promptInfo)
}
}Session Management
Server-side session tracking enables forced logout:
SessionStore {
user_id: String
device_id: String
refresh_token_hash: String
created_at: Timestamp
last_active: Timestamp
revoked: Boolean
}
On every token refresh, the server checks if the session is revoked. If revoked, the refresh fails, and the client must re-authenticate.
Certificate Pinning
val client = OkHttpClient.Builder()
.certificatePinner(
CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // Backup pin
.build()
)
.build()Always include a backup pin. If the primary certificate rotates and the app has only one pin, all clients are locked out.
Trade-offs
| Decision | Upside | Downside |
|---|---|---|
| PKCE | Prevents code interception on mobile | Adds complexity to the auth flow |
| EncryptedSharedPreferences | Easy API, hardware-backed on supported devices | Slower than plain SharedPreferences |
| Short-lived access tokens (15 min) | Limits exposure if token is stolen | More frequent refresh requests |
| Certificate pinning | Prevents MITM attacks | Certificate rotation requires app update or pin rotation strategy |
| Biometric gating | Fast re-auth, no network needed | Not available on all devices, fallback required |
Failure Modes
- Refresh token revoked server-side: Client discovers on next refresh attempt. Must force re-login gracefully, preserving any unsaved user data.
- EncryptedSharedPreferences corruption: Happens on OS upgrades or Keystore changes. Catch
InvalidKeyException, clear the store, and force re-login. - Certificate pin mismatch: All API calls fail. Mitigation: ship a certificate rotation flag via a non-pinned endpoint, or use a pin list with backup certificates.
- Biometric hardware unavailable: Device lacks biometric hardware or enrollment. Always provide a password/PIN fallback.
- Deep link hijacking: A malicious app registers the same redirect URI scheme. Mitigation: use App Links (Android) or Universal Links (iOS) with domain verification instead of custom URI schemes.
Scaling Considerations
- Session management at scale requires a fast lookup store (Redis) for revocation checks on every token refresh.
- Token refresh requests should be load-balanced across auth servers. A single auth server going down should not block refreshes.
- For multi-region deployments, session state must be replicated across regions, or token validation must be self-contained (JWT with short expiry).
Observability
- Track: login success/failure rate, token refresh rate, refresh failure rate, biometric auth usage, session revocation rate.
- Alert on: refresh failure rate exceeding 5% (indicates token store issues or server problems), unusual login patterns (credential stuffing).
- Audit log: every token issuance, refresh, and revocation with device ID, IP, and timestamp.
Key Takeaways
- Always use PKCE for OAuth on mobile. Authorization code interception is a real threat.
- Store tokens in EncryptedSharedPreferences or the Keystore, never in plain SharedPreferences or files.
- Coordinate token refresh across concurrent requests. Multiple simultaneous refresh calls waste resources and can cause race conditions.
- Pin certificates with a backup. A single pin with no backup is a production outage waiting to happen.
- Use App Links/Universal Links for redirect URIs. Custom URI schemes are interceptable by any app.
Further Reading
- Designing an Offline-First Sync Engine for Mobile Apps: A deep dive into building a reliable sync engine that keeps mobile apps functional without connectivity, covering conflict resolution, qu...
- Designing Mobile Systems for Poor Network Conditions: Architecture patterns for mobile apps that function reliably on slow, intermittent, and lossy networks, covering request prioritization, ...
- Designing Background Job Systems for Mobile Apps: Architecture for reliable background job execution on Android, covering WorkManager, job prioritization, constraint handling, and failure...
Final Thoughts
Authentication is the foundation of trust between your app and your users. Every shortcut in auth design is a vulnerability in production. Use standard protocols (OAuth 2.0 + PKCE), store tokens securely, rotate credentials aggressively, and always have a path to force-logout compromised sessions.
Recommended
Designing an Offline-First Sync Engine for Mobile Apps
A deep dive into building a reliable sync engine that keeps mobile apps functional without connectivity, covering conflict resolution, queue management, and real-world trade-offs.
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.