---
name: android-security
description: "Secure Android apps with ProGuard/R8 obfuscation, OkHttp certificate pinning, BiometricPrompt authentication, EncryptedSharedPreferences, Play Integrity API, root detection, secure network communication, and API key protection via local.properties and BuildConfig. Use when implementing security features, auditing app security, handling sensitive data, or preparing for production release."
---

# Android Security

Security patterns for Android apps covering data storage, network security,
authentication, integrity verification, and obfuscation. Targets 2024-2025
best practices with AndroidX Security, BiometricPrompt, and Play Integrity.

## Contents

- [Secure Data Storage](#secure-data-storage)
- [Biometric Authentication](#biometric-authentication)
- [Certificate Pinning](#certificate-pinning)
- [API Key Protection](#api-key-protection)
- [R8 Obfuscation](#r8-obfuscation)
- [Play Integrity API](#play-integrity-api)
- [Root Detection](#root-detection)
- [Secure Network Communication](#secure-network-communication)
- [Secure Coding Patterns](#secure-coding-patterns)
- [Do's and Don'ts](#dos-and-donts)
- [Troubleshooting](#troubleshooting)
- [Review Checklist](#review-checklist)

## Secure Data Storage

### EncryptedSharedPreferences

For sensitive key-value data. Uses AES-256 encryption under the hood.

```kotlin
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

class SecureStorage @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

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

    fun saveToken(token: String) {
        prefs.edit().putString("auth_token", token).apply()
    }

    fun getToken(): String? = prefs.getString("auth_token", null)

    fun clearToken() {
        prefs.edit().remove("auth_token").apply()
    }

    fun clearAll() {
        prefs.edit().clear().apply()
    }
}
```

### Android Keystore

For cryptographic key management. Keys never leave the hardware.

```kotlin
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties

class KeystoreManager {

    companion object {
        private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
        private const val KEY_ALIAS = "app_encryption_key"
        private const val TRANSFORMATION = "AES/GCM/NoPadding"
    }

    private fun getOrCreateKey(): SecretKey {
        val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }

        keyStore.getEntry(KEY_ALIAS, null)?.let { entry ->
            return (entry as KeyStore.SecretKeyEntry).secretKey
        }

        val keyGenerator = KeyGenerator.getInstance(
            KeyProperties.KEY_ALGORITHM_AES,
            KEYSTORE_PROVIDER,
        )

        keyGenerator.init(
            KeyGenParameterSpec.Builder(
                KEY_ALIAS,
                KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
            )
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setKeySize(256)
            .setUserAuthenticationRequired(false)
            .build()
        )

        return keyGenerator.generateKey()
    }

    fun encrypt(data: ByteArray): Pair<ByteArray, ByteArray> {
        val cipher = Cipher.getInstance(TRANSFORMATION)
        cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
        val encrypted = cipher.doFinal(data)
        return Pair(cipher.iv, encrypted)
    }

    fun decrypt(iv: ByteArray, encryptedData: ByteArray): ByteArray {
        val cipher = Cipher.getInstance(TRANSFORMATION)
        val spec = GCMParameterSpec(128, iv)
        cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
        return cipher.doFinal(encryptedData)
    }
}
```

### What Goes Where

| Data Type | Storage | Why |
|-----------|---------|-----|
| Auth tokens | EncryptedSharedPreferences | Encrypted at rest, simple API |
| Passwords | Android Keystore + encryption | Hardware-backed security |
| API keys | BuildConfig (build-time injection) | Not in source; obfuscated by R8 |
| User preferences (non-sensitive) | SharedPreferences / DataStore | No encryption needed |
| Large sensitive files | Encrypted file + Keystore key | AES encryption with hardware key |

## Biometric Authentication

### BiometricPrompt Setup

```kotlin
class BiometricAuthenticator @Inject constructor() {

    fun authenticate(
        activity: FragmentActivity,
        title: String = "Authenticate",
        subtitle: String = "Verify your identity",
        onSuccess: () -> Unit,
        onError: (String) -> Unit,
    ) {
        val biometricManager = BiometricManager.from(activity)
        val canAuthenticate = biometricManager.canAuthenticate(
            BiometricManager.Authenticators.BIOMETRIC_STRONG or
            BiometricManager.Authenticators.DEVICE_CREDENTIAL
        )

        if (canAuthenticate != BiometricManager.BIOMETRIC_SUCCESS) {
            onError(mapBiometricError(canAuthenticate))
            return
        }

        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle(title)
            .setSubtitle(subtitle)
            .setAllowedAuthenticators(
                BiometricManager.Authenticators.BIOMETRIC_STRONG or
                BiometricManager.Authenticators.DEVICE_CREDENTIAL
            )
            .build()

        val callback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                onSuccess()
            }

            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                onError(errString.toString())
            }

            override fun onAuthenticationFailed() {
                // Called on failed attempt but user can retry
            }
        }

        val prompt = BiometricPrompt(activity, callback)
        prompt.authenticate(promptInfo)
    }

    private fun mapBiometricError(code: Int): String = when (code) {
        BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> "No biometric hardware"
        BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> "Biometric hardware unavailable"
        BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> "No biometrics enrolled"
        else -> "Biometric authentication unavailable"
    }
}
```

### Biometric + Keystore (Crypto-Based Auth)

```kotlin
fun authenticateWithCrypto(
    activity: FragmentActivity,
    onSuccess: (Cipher) -> Unit,
) {
    val key = getOrCreateBiometricKey()
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.ENCRYPT_MODE, key)

    val cryptoObject = BiometricPrompt.CryptoObject(cipher)

    val prompt = BiometricPrompt(activity, object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
            result.cryptoObject?.cipher?.let(onSuccess)
        }
    })

    prompt.authenticate(promptInfo, cryptoObject)
}

private fun getOrCreateBiometricKey(): SecretKey {
    val keyGenerator = KeyGenerator.getInstance("AES", "AndroidKeyStore")
    keyGenerator.init(
        KeyGenParameterSpec.Builder("biometric_key",
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setUserAuthenticationRequired(true)
            .setInvalidatedByBiometricEnrollment(true)
            .build()
    )
    return keyGenerator.generateKey()
}
```

## Certificate Pinning

### OkHttp CertificatePinner

```kotlin
val certificatePinner = CertificatePinner.Builder()
    .add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // Primary
    .add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // Backup
    .build()

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

### Generating Pin Hashes

```bash
# From a domain
openssl s_client -connect api.example.com:443 -servername api.example.com \
    | openssl x509 -pubkey -noout \
    | openssl pkey -pubin -outform der \
    | openssl dgst -sha256 -binary \
    | openssl enc -base64

# From a certificate file
openssl x509 -in cert.pem -pubkey -noout \
    | openssl pkey -pubin -outform der \
    | openssl dgst -sha256 -binary \
    | openssl enc -base64
```

### Network Security Config (XML-based)

```xml
<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="false">
        <domain includeSubdomains="true">api.example.com</domain>
        <pin-set expiration="2026-01-01">
            <pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
            <pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
        </pin-set>
    </domain-config>

    <!-- Block cleartext for all domains -->
    <base-config cleartextTrafficPermitted="false" />
</network-security-config>
```

```xml
<!-- AndroidManifest.xml -->
<application android:networkSecurityConfig="@xml/network_security_config">
```

## API Key Protection

### local.properties (Gitignored)

```properties
# local.properties -- NEVER commit this file
MAPS_API_KEY=AIzaSyB...
BASE_URL=https://api.example.com/
```

### Build-Time Injection

```kotlin
// build.gradle.kts
import java.util.Properties

val localProperties = Properties().apply {
    val file = rootProject.file("local.properties")
    if (file.exists()) load(file.inputStream())
}

android {
    defaultConfig {
        buildConfigField("String", "MAPS_API_KEY",
            "\"${localProperties["MAPS_API_KEY"] ?: ""}\"")
        buildConfigField("String", "BASE_URL",
            "\"${localProperties["BASE_URL"] ?: "https://api.example.com/"}\"")
    }
}
```

### Usage in Code

```kotlin
// Access via BuildConfig -- obfuscated by R8 in release builds
val apiKey = BuildConfig.MAPS_API_KEY
val baseUrl = BuildConfig.BASE_URL
```

### Manifest Placeholder

```kotlin
android {
    defaultConfig {
        manifestPlaceholders["MAPS_API_KEY"] = localProperties["MAPS_API_KEY"] ?: ""
    }
}
```

```xml
<meta-data
    android:name="com.google.android.geo.API_KEY"
    android:value="${MAPS_API_KEY}" />
```

## R8 Obfuscation

R8 is enabled automatically when `isMinifyEnabled = true`. It performs:
- Code shrinking (removes unused code)
- Resource shrinking (removes unused resources)
- Obfuscation (renames classes/methods)
- Optimization (inlining, dead code elimination)

### Mapping File

R8 generates `mapping.txt` for deobfuscating crash reports:

```kotlin
android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro",
            )
        }
    }
}
```

Upload `mapping.txt` to Google Play Console and Firebase Crashlytics for
readable stack traces.

## Play Integrity API

Verify that API requests come from a genuine app on a genuine device.

```kotlin
class IntegrityVerifier @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    suspend fun getIntegrityToken(nonce: String): String? {
        return try {
            val integrityManager = IntegrityManagerFactory.create(context)

            val request = IntegrityTokenRequest.builder()
                .setNonce(nonce)
                .build()

            val response = integrityManager
                .requestIntegrityToken(request)
                .await()

            response.token()
        } catch (e: Exception) {
            null
        }
    }
}
```

Send the token to your server for verification. Never verify integrity tokens
on the client -- the server must decode and validate them via the Play Integrity
API.

## Root Detection

Root detection is defense-in-depth. Determined attackers can bypass it, but it
raises the bar.

```kotlin
object RootDetector {

    fun isDeviceRooted(): Boolean =
        checkRootBinaries() || checkSuExists() || checkRootApps()

    private fun checkRootBinaries(): Boolean {
        val paths = listOf(
            "/system/bin/su",
            "/system/xbin/su",
            "/sbin/su",
            "/system/app/Superuser.apk",
            "/system/app/SuperSU.apk",
        )
        return paths.any { File(it).exists() }
    }

    private fun checkSuExists(): Boolean = try {
        Runtime.getRuntime().exec("which su").inputStream.bufferedReader().readLine() != null
    } catch (e: Exception) {
        false
    }

    private fun checkRootApps(): Boolean {
        val rootPackages = listOf(
            "com.topjohnwu.magisk",
            "eu.chainfire.supersu",
            "com.koushikdutta.superuser",
        )
        // Check if any are installed (requires QUERY_ALL_PACKAGES or specific queries)
        return false // Simplified -- use PackageManager in production
    }
}
```

Use root detection as one signal among many. Combine with Play Integrity for
stronger guarantees.

## Secure Network Communication

### Enforce HTTPS

```xml
<!-- network_security_config.xml -->
<network-security-config>
    <base-config cleartextTrafficPermitted="false" />
</network-security-config>
```

### Debug-Only Cleartext (for local development)

```xml
<network-security-config>
    <base-config cleartextTrafficPermitted="false" />
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">10.0.2.2</domain>
        <domain includeSubdomains="false">localhost</domain>
    </domain-config>
</network-security-config>
```

### TLS Configuration

```kotlin
val spec = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
    .tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_3)
    .build()

val client = OkHttpClient.Builder()
    .connectionSpecs(listOf(spec))
    .build()
```

## Secure Coding Patterns

### Never Log Sensitive Data

```kotlin
// WRONG
Log.d("Auth", "Token: $token")
Log.d("Payment", "Card: $cardNumber")

// CORRECT
Log.d("Auth", "Token refresh successful")
Log.d("Payment", "Payment processed")
```

### Input Validation

```kotlin
fun validateDeepLink(uri: Uri): Boolean {
    val allowedHosts = setOf("example.com", "www.example.com")
    val allowedSchemes = setOf("https")

    return uri.scheme in allowedSchemes &&
           uri.host in allowedHosts &&
           !uri.path.orEmpty().contains("..")
}
```

### Secure WebView

```kotlin
webView.settings.apply {
    javaScriptEnabled = false       // Enable ONLY if required
    allowFileAccess = false
    allowContentAccess = false
    domStorageEnabled = false
}
```

## Do's and Don'ts

### Do's
- Use EncryptedSharedPreferences for sensitive key-value data
- Use Android Keystore for cryptographic keys
- Enable R8 with `isMinifyEnabled = true` for release builds
- Upload `mapping.txt` to Play Console and Crashlytics
- Use Network Security Config to enforce HTTPS
- Include at least two certificate pins (primary + backup)
- Verify Play Integrity tokens server-side only
- Use BiometricPrompt with BIOMETRIC_STRONG for high-security flows

### Don'ts
- Do not store secrets in SharedPreferences (use EncryptedSharedPreferences)
- Do not hardcode API keys in Kotlin/Java source files
- Do not commit `local.properties` or keystore files to version control
- Do not log tokens, passwords, or card numbers
- Do not verify integrity tokens on the client
- Do not trust root detection as the sole security measure
- Do not enable JavaScript in WebView unless absolutely necessary
- Do not use `cleartextTrafficPermitted="true"` in production

## Troubleshooting

| Problem | Cause | Fix |
|---------|-------|-----|
| `KeyPermanentlyInvalidatedException` | Biometric enrollment changed | Delete and recreate the Keystore key |
| EncryptedSharedPreferences crash on upgrade | MasterKey corrupted | Handle exception, recreate storage, re-authenticate user |
| Certificate pinning fails after cert rotation | Pin hash mismatch | Update pins; always maintain a backup pin |
| R8 strips serialization classes | Missing keep rules | Add `-keep` rules for `@Serializable` classes |
| Play Integrity token is null | Missing Play Core dependency or not on Play device | Check `com.google.android.play:integrity` dependency |
| Cleartext traffic blocked | Network Security Config enforcing HTTPS | Use HTTPS or add debug-only exception for local dev |
| BiometricPrompt not showing | Wrong authenticator type or no enrollment | Check `canAuthenticate()` before prompting |
| Keystore key inaccessible after OS update | Keystore compatibility issue | Wrap in try/catch; recreate key if needed |

## Review Checklist

- [ ] Sensitive data in EncryptedSharedPreferences or Keystore, not plain SharedPreferences
- [ ] No hardcoded API keys or secrets in source files
- [ ] `local.properties` is in `.gitignore`
- [ ] R8 enabled with `isMinifyEnabled = true` for release
- [ ] `mapping.txt` uploaded to Play Console and Crashlytics
- [ ] Network Security Config enforces HTTPS (`cleartextTrafficPermitted="false"`)
- [ ] Certificate pinning with primary + backup pins
- [ ] BiometricPrompt uses `BIOMETRIC_STRONG` for sensitive operations
- [ ] No sensitive data in logs
- [ ] Play Integrity tokens verified server-side
- [ ] WebView has minimal permissions (JavaScript disabled by default)
- [ ] Input and deep link URLs validated before processing
