Designing Secure Auth Flows for Mobile Applications

Dhruval Dhameliya·August 22, 2025·7 min read

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

ConstraintDetail
Token storageKeystore/Keychain available, but not all devices support hardware-backed keys
NetworkAll auth traffic must use TLS 1.2+; certificate pinning recommended
Session durationAccess tokens: 15-30 minutes. Refresh tokens: 30-90 days
UXToken refresh must be invisible; re-authentication should be rare
ComplianceMust 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

DecisionUpsideDownside
PKCEPrevents code interception on mobileAdds complexity to the auth flow
EncryptedSharedPreferencesEasy API, hardware-backed on supported devicesSlower than plain SharedPreferences
Short-lived access tokens (15 min)Limits exposure if token is stolenMore frequent refresh requests
Certificate pinningPrevents MITM attacksCertificate rotation requires app update or pin rotation strategy
Biometric gatingFast re-auth, no network neededNot 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

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