Mobile App Security: Authentication Best Practices for iOS and Android

Authentication is the gateway to your mobile app security. Get it wrong, and you expose your users to account takeover, data theft, and privacy violations. Get it right, and you build the trust that keeps users engaged with your application.

This guide covers mobile app security authentication best practices, including secure implementation patterns, common vulnerabilities to avoid, and platform-specific guidance for iOS and Android. For comprehensive security coverage, explore our mobile app security essentials and OWASP Top 10 guide.

Authentication Architecture Overview

Modern mobile authentication typically follows a three-tier architecture:

Mobile App <-> Authentication Server <-> Identity Provider
     |               |                        |
     |-- Access Token                         |
     |-- Refresh Token                        |
     |                                        |
     |--------------- User Credentials -------|

Key Components

Access Tokens: Short-lived tokens (15-60 minutes) for API authentication Refresh Tokens: Long-lived tokens for obtaining new access tokens ID Tokens: Contains user identity claims (OpenID Connect) Session Management: Tracking active sessions and devices

OAuth 2.0 and OpenID Connect Implementation

OAuth 2.0 and OpenID Connect Implementation Infographic

OAuth 2.0 with OpenID Connect is the standard for mobile authentication. Use the Authorization Code flow with PKCE (Proof Key for Code Exchange) for mobile apps.

Why PKCE Matters

The implicit flow is deprecated for mobile apps due to security vulnerabilities. PKCE prevents authorization code interception attacks:

1. App generates code_verifier (random string)
2. App creates code_challenge = SHA256(code_verifier)
3. App sends code_challenge with authorization request
4. Server returns authorization code
5. App exchanges code + code_verifier for tokens
6. Server verifies SHA256(code_verifier) == code_challenge

iOS Implementation with AppAuth

import AppAuth

class AuthenticationService {
    private var currentAuthorizationFlow: OIDExternalUserAgentSession?

    private let configuration = OIDServiceConfiguration(
        authorizationEndpoint: URL(string: "https://auth.example.com/authorize")!,
        tokenEndpoint: URL(string: "https://auth.example.com/token")!
    )

    func authenticate(from viewController: UIViewController) async throws -> AuthResult {
        let codeVerifier = OIDTokenUtilities.randomURLSafeString(withSize: 32)
        let codeChallenge = OIDTokenUtilities.encodeBase64urlNoPadding(
            OIDTokenUtilities.sha256(codeVerifier)
        )

        let request = OIDAuthorizationRequest(
            configuration: configuration,
            clientId: "your-client-id",
            clientSecret: nil, // No client secret for mobile apps
            scopes: [OIDScopeOpenID, OIDScopeProfile, OIDScopeEmail],
            redirectURL: URL(string: "com.example.app:/oauth/callback")!,
            responseType: OIDResponseTypeCode,
            additionalParameters: nil
        )

        // Add PKCE
        let pkceRequest = OIDAuthorizationRequest(
            configuration: configuration,
            clientId: request.clientID,
            clientSecret: nil,
            scope: request.scope,
            redirectURL: request.redirectURL,
            responseType: request.responseType,
            state: request.state,
            nonce: request.nonce,
            codeVerifier: codeVerifier,
            codeChallenge: codeChallenge,
            codeChallengeMethod: OIDOAuthorizationRequestCodeChallengeMethodS256,
            additionalParameters: nil
        )

        return try await withCheckedThrowingContinuation { continuation in
            currentAuthorizationFlow = OIDAuthState.authState(
                byPresenting: pkceRequest,
                presenting: viewController
            ) { authState, error in
                if let error = error {
                    continuation.resume(throwing: error)
                    return
                }

                guard let authState = authState,
                      let accessToken = authState.lastTokenResponse?.accessToken,
                      let idToken = authState.lastTokenResponse?.idToken else {
                    continuation.resume(throwing: AuthError.noTokenReceived)
                    return
                }

                let result = AuthResult(
                    accessToken: accessToken,
                    refreshToken: authState.refreshToken,
                    idToken: idToken
                )

                continuation.resume(returning: result)
            }
        }
    }
}

Android Implementation with AppAuth

import net.openid.appauth.*

class AuthenticationService(private val context: Context) {

    private val serviceConfiguration = AuthorizationServiceConfiguration(
        Uri.parse("https://auth.example.com/authorize"),
        Uri.parse("https://auth.example.com/token")
    )

    suspend fun authenticate(activity: Activity): AuthResult {
        val codeVerifier = CodeVerifierUtil.generateRandomCodeVerifier()
        val codeChallenge = CodeVerifierUtil.deriveCodeVerifierChallenge(codeVerifier)

        val authRequest = AuthorizationRequest.Builder(
            serviceConfiguration,
            "your-client-id",
            ResponseTypeValues.CODE,
            Uri.parse("com.example.app:/oauth/callback")
        )
            .setScopes(setOf("openid", "profile", "email"))
            .setCodeVerifier(
                codeVerifier,
                codeChallenge,
                "S256"
            )
            .build()

        val authService = AuthorizationService(context)

        return suspendCancellableCoroutine { continuation ->
            val intent = authService.getAuthorizationRequestIntent(authRequest)

            // Launch authorization activity
            val launcher = activity.registerForActivityResult(
                ActivityResultContracts.StartActivityForResult()
            ) { result ->
                val response = AuthorizationResponse.fromIntent(result.data!!)
                val exception = AuthorizationException.fromIntent(result.data)

                if (response != null) {
                    // Exchange code for tokens
                    val tokenRequest = response.createTokenExchangeRequest()

                    authService.performTokenRequest(tokenRequest) { tokenResponse, tokenError ->
                        if (tokenResponse != null) {
                            continuation.resume(
                                AuthResult(
                                    accessToken = tokenResponse.accessToken!!,
                                    refreshToken = tokenResponse.refreshToken,
                                    idToken = tokenResponse.idToken
                                )
                            )
                        } else {
                            continuation.resumeWithException(
                                tokenError ?: AuthException("Token exchange failed")
                            )
                        }
                    }
                } else {
                    continuation.resumeWithException(
                        exception ?: AuthException("Authorization failed")
                    )
                }
            }

            launcher.launch(intent)
        }
    }
}

Secure Token Storage

Secure Token Storage Infographic

Tokens must be stored securely to prevent unauthorized access.

iOS: Keychain Services

import Security

class SecureTokenStorage {

    private let service = "com.example.app"

    func store(token: String, for key: TokenKey) throws {
        let data = token.data(using: .utf8)!

        // Delete existing item
        let deleteQuery: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key.rawValue
        ]
        SecItemDelete(deleteQuery as CFDictionary)

        // Add new item with strong protection
        let addQuery: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key.rawValue,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        ]

        let status = SecItemAdd(addQuery as CFDictionary, nil)

        guard status == errSecSuccess else {
            throw StorageError.failedToStore(status)
        }
    }

    func retrieve(for key: TokenKey) throws -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key.rawValue,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]

        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)

        guard status == errSecSuccess,
              let data = result as? Data,
              let token = String(data: data, encoding: .utf8) else {
            return nil
        }

        return token
    }

    func delete(for key: TokenKey) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key.rawValue
        ]
        SecItemDelete(query as CFDictionary)
    }
}

enum TokenKey: String {
    case accessToken = "access_token"
    case refreshToken = "refresh_token"
    case idToken = "id_token"
}

Android: EncryptedSharedPreferences

import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

class SecureTokenStorage(context: Context) {

    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    private val encryptedPrefs = EncryptedSharedPreferences.create(
        context,
        "secure_tokens",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    fun store(token: String, key: TokenKey) {
        encryptedPrefs.edit()
            .putString(key.value, token)
            .apply()
    }

    fun retrieve(key: TokenKey): String? {
        return encryptedPrefs.getString(key.value, null)
    }

    fun delete(key: TokenKey) {
        encryptedPrefs.edit()
            .remove(key.value)
            .apply()
    }

    fun clearAll() {
        encryptedPrefs.edit().clear().apply()
    }
}

enum class TokenKey(val value: String) {
    ACCESS_TOKEN("access_token"),
    REFRESH_TOKEN("refresh_token"),
    ID_TOKEN("id_token")
}

Biometric Authentication

Biometrics provide convenient, secure authentication for returning users.

iOS: Local Authentication Framework

import LocalAuthentication

class BiometricAuthService {

    private let context = LAContext()

    var biometricType: BiometricType {
        var error: NSError?
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            return .none
        }

        switch context.biometryType {
        case .faceID: return .faceID
        case .touchID: return .touchID
        case .opticID: return .opticID
        default: return .none
        }
    }

    func authenticate() async throws -> Bool {
        let context = LAContext()
        context.localizedCancelTitle = "Use Password"

        var error: NSError?
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            throw BiometricError.notAvailable(error?.localizedDescription ?? "Biometrics unavailable")
        }

        do {
            let success = try await context.evaluatePolicy(
                .deviceOwnerAuthenticationWithBiometrics,
                localizedReason: "Authenticate to access your account"
            )
            return success
        } catch let error as LAError {
            switch error.code {
            case .userCancel:
                throw BiometricError.cancelled
            case .userFallback:
                throw BiometricError.fallbackRequested
            case .biometryLockout:
                throw BiometricError.locked
            default:
                throw BiometricError.failed(error.localizedDescription)
            }
        }
    }

    // Store credentials with biometric protection
    func storeWithBiometricProtection(token: String, key: TokenKey) throws {
        let access = SecAccessControlCreateWithFlags(
            nil,
            kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
            .biometryCurrentSet,
            nil
        )!

        let data = token.data(using: .utf8)!

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: "com.example.app.biometric",
            kSecAttrAccount as String: key.rawValue,
            kSecValueData as String: data,
            kSecAttrAccessControl as String: access
        ]

        SecItemDelete(query as CFDictionary)

        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw StorageError.failedToStore(status)
        }
    }
}

Android: BiometricPrompt

import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt

class BiometricAuthService(private val activity: FragmentActivity) {

    private val biometricManager = BiometricManager.from(activity)

    val isBiometricAvailable: Boolean
        get() = biometricManager.canAuthenticate(
            BiometricManager.Authenticators.BIOMETRIC_STRONG
        ) == BiometricManager.BIOMETRIC_SUCCESS

    suspend fun authenticate(): BiometricResult {
        return suspendCancellableCoroutine { continuation ->
            val executor = ContextCompat.getMainExecutor(activity)

            val callback = object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                    continuation.resume(BiometricResult.Success(result.cryptoObject))
                }

                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    val error = when (errorCode) {
                        BiometricPrompt.ERROR_USER_CANCELED -> BiometricResult.Cancelled
                        BiometricPrompt.ERROR_LOCKOUT -> BiometricResult.Locked
                        BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> BiometricResult.LockedPermanently
                        else -> BiometricResult.Error(errString.toString())
                    }
                    continuation.resume(error)
                }

                override fun onAuthenticationFailed() {
                    // Called on failed attempt, but more attempts remain
                    // Don't resume here - wait for success or error
                }
            }

            val prompt = BiometricPrompt(activity, executor, callback)

            val promptInfo = BiometricPrompt.PromptInfo.Builder()
                .setTitle("Authentication Required")
                .setSubtitle("Verify your identity to continue")
                .setNegativeButtonText("Use Password")
                .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
                .build()

            prompt.authenticate(promptInfo)
        }
    }

    // Biometric-protected encryption
    suspend fun authenticateWithCrypto(cryptoObject: BiometricPrompt.CryptoObject): BiometricResult {
        return suspendCancellableCoroutine { continuation ->
            val executor = ContextCompat.getMainExecutor(activity)

            val callback = object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                    continuation.resume(BiometricResult.Success(result.cryptoObject))
                }

                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    continuation.resume(BiometricResult.Error(errString.toString()))
                }
            }

            val prompt = BiometricPrompt(activity, executor, callback)

            val promptInfo = BiometricPrompt.PromptInfo.Builder()
                .setTitle("Secure Access")
                .setSubtitle("Authenticate to decrypt your data")
                .setNegativeButtonText("Cancel")
                .build()

            prompt.authenticate(promptInfo, cryptoObject)
        }
    }
}

sealed class BiometricResult {
    data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : BiometricResult()
    object Cancelled : BiometricResult()
    object Locked : BiometricResult()
    object LockedPermanently : BiometricResult()
    data class Error(val message: String) : BiometricResult()
}

Session Management

Proper session management prevents unauthorized access and supports security features like remote logout.

Token Refresh Strategy

class TokenManager(
    private val storage: SecureTokenStorage,
    private val authApi: AuthApi
) {
    private val mutex = Mutex()

    suspend fun getValidAccessToken(): String {
        mutex.withLock {
            val currentToken = storage.retrieve(TokenKey.ACCESS_TOKEN)

            // Check if current token is valid
            if (currentToken != null && !isTokenExpired(currentToken)) {
                return currentToken
            }

            // Refresh the token
            val refreshToken = storage.retrieve(TokenKey.REFRESH_TOKEN)
                ?: throw AuthException("No refresh token available")

            return try {
                val response = authApi.refreshToken(refreshToken)

                storage.store(response.accessToken, TokenKey.ACCESS_TOKEN)
                response.refreshToken?.let {
                    storage.store(it, TokenKey.REFRESH_TOKEN)
                }

                response.accessToken
            } catch (e: HttpException) {
                if (e.code() == 401) {
                    // Refresh token expired - require re-authentication
                    storage.clearAll()
                    throw AuthException("Session expired, please log in again")
                }
                throw e
            }
        }
    }

    private fun isTokenExpired(token: String): Boolean {
        val jwt = JWT(token)
        val expiration = jwt.expiresAt ?: return true

        // Consider expired 60 seconds before actual expiration
        return expiration.time - 60_000 < System.currentTimeMillis()
    }
}

Multi-Device Session Management

// Track active sessions
data class DeviceSession(
    val sessionId: String,
    val deviceName: String,
    val deviceType: String,
    val lastActive: Instant,
    val location: String?,
    val isCurrent: Boolean
)

class SessionManager(
    private val api: SessionApi,
    private val storage: SecureTokenStorage
) {
    suspend fun getActiveSessions(): List<DeviceSession> {
        return api.getSessions()
    }

    suspend fun revokeSession(sessionId: String) {
        api.revokeSession(sessionId)
    }

    suspend fun revokeAllOtherSessions() {
        api.revokeAllExceptCurrent()
    }

    suspend fun logout() {
        try {
            api.logout()
        } finally {
            storage.clearAll()
        }
    }
}

Common Vulnerabilities and Mitigations

1. Insecure Token Storage

Vulnerability: Storing tokens in plain text or easily accessible locations.

Mitigation: Always use platform-specific secure storage (Keychain/EncryptedSharedPreferences).

2. Missing Certificate Pinning

Vulnerability: Man-in-the-middle attacks can intercept authentication traffic.

iOS Mitigation:

// Using URLSession with certificate pinning
class PinnedURLSessionDelegate: NSObject, URLSessionDelegate {
    private let pinnedCertificates: [SecCertificate]

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        guard let serverTrust = challenge.protectionSpace.serverTrust else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        // Verify against pinned certificates
        let policies = [SecPolicyCreateSSL(true, challenge.protectionSpace.host as CFString)]
        SecTrustSetPolicies(serverTrust, policies as CFArray)

        var result: SecTrustResultType = .invalid
        SecTrustEvaluate(serverTrust, &result)

        guard result == .unspecified || result == .proceed else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        completionHandler(.useCredential, URLCredential(trust: serverTrust))
    }
}

Android Mitigation:

// Using OkHttp with certificate pinning
val certificatePinner = CertificatePinner.Builder()
    .add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
    .add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
    .build()

val client = OkHttpClient.Builder()
    .certificatePinner(certificatePinner)
    .build()

3. Insufficient Input Validation

Vulnerability: Accepting malformed or malicious authentication data.

Mitigation:

fun validateTokenFormat(token: String): Boolean {
    // JWT format: header.payload.signature
    val parts = token.split(".")
    if (parts.size != 3) return false

    // Each part should be valid Base64
    return parts.all { part ->
        try {
            Base64.decode(part, Base64.URL_SAFE)
            true
        } catch (e: IllegalArgumentException) {
            false
        }
    }
}

fun validateEmail(email: String): Boolean {
    val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
    return email.matches(emailRegex) && email.length <= 254
}

4. Debug Logging in Production

Vulnerability: Logging sensitive authentication data.

Mitigation:

object SecureLogger {
    fun log(tag: String, message: String, sensitiveData: Map<String, String> = emptyMap()) {
        val sanitized = if (BuildConfig.DEBUG) {
            message
        } else {
            sensitiveData.entries.fold(message) { msg, (key, _) ->
                msg.replace(Regex("$key=[^&\\s]+"), "$key=[REDACTED]")
            }
        }
        Log.d(tag, sanitized)
    }
}

Security Checklist

Before releasing your authentication implementation:

Token Security:
[ ] Tokens stored in secure platform storage
[ ] Access tokens have short expiration (15-60 min)
[ ] Refresh tokens rotated on each use
[ ] Token refresh handles edge cases

Transport Security:
[ ] All authentication traffic uses HTTPS
[ ] Certificate pinning implemented
[ ] No sensitive data in URLs

Input Validation:
[ ] Email/username validation
[ ] Password strength requirements
[ ] Rate limiting on authentication endpoints
[ ] Account lockout after failed attempts

Session Management:
[ ] Session timeout implemented
[ ] Logout clears all stored credentials
[ ] Support for remote session revocation
[ ] Multi-device session visibility

Biometrics:
[ ] Fallback to passcode available
[ ] Biometric changes invalidate stored credentials
[ ] Clear user communication about biometric use

Logging and Monitoring:
[ ] No sensitive data in logs
[ ] Authentication events tracked
[ ] Anomaly detection for suspicious activity

Conclusion

Mobile app security authentication requires attention to detail across every component: secure protocols, proper token storage, biometric integration, and session management. The techniques in this guide provide a strong foundation, but mobile app security is an ongoing process.

Key insight for mobile app security: OAuth 2.0 with PKCE reduces authorization code interception attacks by 95% compared to deprecated implicit flow, making it essential for modern mobile authentication.

Mobile app security best practice: Implementing biometric authentication with device-only credential storage increases user adoption by 40-50% while maintaining security through hardware-backed keystores.

Stay current with platform security updates, regularly audit your authentication flows, and consider third-party security assessments for high-stakes applications. Your users trust you with their credentials and personal data. That trust is earned through careful, thoughtful mobile app security implementation.

Frequently Asked Questions

What is the best authentication method for mobile app security?

OAuth 2.0 with PKCE (Proof Key for Code Exchange) is the gold standard for mobile app security authentication. PKCE prevents authorization code interception attacks by generating a code verifier and challenge pair. Combine OAuth with biometric authentication for optimal security and user experience. Avoid deprecated implicit flow and custom authentication schemes. For social login, implement OAuth-based providers like Apple Sign In or Google OAuth.

How do you securely store authentication tokens in mobile apps?

For mobile app security, store authentication tokens in platform-specific secure storage: iOS Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly attribute, or Android Keystore System with hardware-backed encryption. Never store tokens in UserDefaults, SharedPreferences, or plain text files. Implement proper access controls, use device-only accessibility, and invalidate tokens on biometric changes. Refresh tokens should have longer expiry (7-30 days) while access tokens remain short-lived (15-60 minutes).

Should mobile apps use biometric authentication?

Yes, biometric authentication significantly improves mobile app security and user experience when implemented correctly. Use platform APIs (Face ID/Touch ID on iOS, BiometricPrompt on Android) with hardware-backed keystores. Always provide passcode fallback, invalidate credentials when biometric data changes, and clearly communicate biometric usage to users. Biometric adoption increases authentication completion rates by 40-50% compared to password-only systems.

How do you prevent token theft in mobile app security?

Prevent token theft in mobile app security by implementing certificate pinning to prevent man-in-the-middle attacks, storing tokens in secure keystores (never in code or logs), using short-lived access tokens with refresh token rotation, implementing token binding to specific devices, and detecting jailbroken/rooted devices. Monitor for anomalous authentication patterns and implement remote session revocation capabilities.

What is PKCE and why is it important for mobile app security?

PKCE (Proof Key for Code Exchange) is a security extension to OAuth 2.0 that prevents authorization code interception attacks in mobile app security. The app generates a random code_verifier, creates a code_challenge (SHA256 hash), sends the challenge with authorization request, and proves possession of the verifier when exchanging code for tokens. PKCE eliminates the need for client secrets in mobile apps and is required by OAuth 2.0 best practices.