You built the app. It works. But before you ship it, you need to think about security. Can someone decompile your APK and read your code? Is the user’s data safe? Can a man-in-the-middle attack steal API tokens?
In this tutorial, you will learn how to protect your Android app — from code shrinking with R8 to biometric authentication and encrypted storage.
Prerequisites: You should have a working Android app. If you have been following this series, you already have everything you need.
Why App Security Matters
Every APK can be decompiled. Tools like jadx and apktool turn your app back into readable code in seconds. Without protection:
- Your API keys are visible
- Your business logic is exposed
- Competitors can clone your app
- Attackers can find vulnerabilities
Security is not about making your app impossible to hack. It is about making it hard enough that attackers move on to easier targets.
R8 — Code Shrinking and Obfuscation
R8 is Android’s built-in code optimizer. It does three things:
- Shrinking — removes unused classes, methods, and fields
- Obfuscation — renames classes and methods to meaningless names
- Optimization — inlines code, removes dead branches
R8 replaced ProGuard as the default optimizer in AGP 3.4. You still write “ProGuard rules” but R8 processes them.
Enabling R8
In your app’s build.gradle.kts:
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
isMinifyEnabled = true— enables code shrinking and obfuscationisShrinkResources = true— removes unused resources (images, layouts)proguard-android-optimize.txt— default rules from the Android SDKproguard-rules.pro— your custom rules
What R8 Does to Your Code
Before R8:
class UserRepository(private val api: UserApi) {
suspend fun getUsers(): List<User> {
return api.getUsers().map { it.toDomain() }
}
}
After R8:
class a(private val b: c) {
suspend fun d(): List<e> {
return b.f().map { it.g() }
}
}
Class names, method names, and variable names become single letters. Anyone decompiling your APK sees meaningless code.
APK Size Impact
R8 typically reduces APK size by 20-40%. For a 10 MB APK:
| Optimization | Size |
|---|---|
| No R8 | 10.0 MB |
| Code shrinking only | 7.5 MB |
| + Resource shrinking | 6.8 MB |
| + R8 full mode | 6.2 MB |
Use APK Analyzer in Android Studio (Build → Analyze APK) to see exactly what takes space.
ProGuard Rules
R8 removes code it thinks is unused. Sometimes it removes code you actually need — like classes accessed through reflection or serialization.
Keep Rules Syntax
# Keep the entire class
-keep class com.example.model.User { *; }
# Keep class members only
-keepclassmembers class com.example.model.User {
<fields>;
}
# Keep classes with a specific annotation
-keep @com.google.gson.annotations.SerializedName class * { *; }
# Keep class names but allow optimization
-keepnames class com.example.** { *; }
Rules for Common Libraries
Kotlin Serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers @kotlinx.serialization.Serializable class ** {
*** Companion;
}
-keepclasseswithmembers class **$$serializer {
*** INSTANCE;
}
Retrofit
-keepattributes Signature
-keepattributes Exceptions
-keep,allowshrinking,allowobfuscation interface retrofit2.Call
-keep,allowshrinking,allowobfuscation interface retrofit2.Response
-keepclassmembers,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**
Hilt
Hilt generates its own keep rules automatically. You usually don’t need custom rules. But if you use @EntryPoint:
-keep @dagger.hilt.EntryPoint class *
R8 Full Mode
R8 full mode applies more aggressive optimizations:
android {
buildTypes {
release {
// In gradle.properties:
// android.enableR8.fullMode=true
}
}
}
Full mode may break reflection-based libraries. Test your release build thoroughly.
Debugging R8 Issues
When R8 removes something it shouldn’t:
# Build with mapping file
./gradlew assembleRelease
# Find the mapping file
# app/build/outputs/mapping/release/mapping.txt
The mapping file translates obfuscated names back to original names. Upload this to Play Console and Firebase Crashlytics for readable crash reports.
To see what R8 removed:
# Add to proguard-rules.pro
-printusage usage.txt
-printseeds seeds.txt
Encrypted Storage
For sensitive data like tokens, passwords, or API keys, use encrypted storage.
EncryptedSharedPreferences
Note:
EncryptedSharedPreferencesfromandroidx.security:security-cryptowas deprecated in April 2025. It still works but is no longer actively developed. For new projects, prefer DataStore with a custom encryption layer or Google Tink.
fun createEncryptedPrefs(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
Use it like regular SharedPreferences:
val prefs = createEncryptedPrefs(context)
// Write
prefs.edit().putString("auth_token", "abc123").apply()
// Read
val token = prefs.getString("auth_token", null)
Encrypted Storage Wrapper
For a cleaner API, wrap encrypted preferences in a helper class:
class EncryptedStorage(private val context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val encryptedPrefs = EncryptedSharedPreferences.create(
context,
"encrypted_datastore",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveToken(token: String) {
encryptedPrefs.edit().putString("auth_token", token).apply()
}
fun getToken(): String? {
return encryptedPrefs.getString("auth_token", null)
}
fun clearToken() {
encryptedPrefs.edit().remove("auth_token").apply()
}
}
Provide it with Hilt:
@Module
@InstallIn(SingletonComponent::class)
object SecurityModule {
@Provides
@Singleton
fun provideEncryptedDataStore(
@ApplicationContext context: Context
): EncryptedStorage = EncryptedStorage(context)
}
What to Encrypt
| Data | Storage | Encrypt? |
|---|---|---|
| Auth tokens | EncryptedSharedPreferences | Yes |
| API keys | BuildConfig or encrypted storage | Yes |
| User preferences (theme, language) | DataStore | No |
| Cache data | Room / files | No |
| Credit card numbers | Don’t store locally | — |
| Passwords | Never store — use tokens | — |
Biometric Authentication
BiometricPrompt lets users authenticate with fingerprint or face recognition.
Setup
dependencies {
implementation("androidx.biometric:biometric:1.1.0") // Latest stable — alpha builds (1.4.0-alpha05) add Compose-specific APIs but are unstable
}
Checking Biometric Availability
fun canUseBiometrics(context: Context): Boolean {
val biometricManager = BiometricManager.from(context)
return biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_STRONG
) == BiometricManager.BIOMETRIC_SUCCESS
}
Showing the Biometric Prompt
@Composable
fun BiometricScreen() {
val context = LocalContext.current
var isAuthenticated by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val activity = context as FragmentActivity
fun showBiometricPrompt() {
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Verify Identity")
.setSubtitle("Use your fingerprint to access the app")
.setNegativeButtonText("Cancel")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_STRONG
)
.build()
val biometricPrompt = BiometricPrompt(
activity,
ContextCompat.getMainExecutor(context),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
isAuthenticated = true
}
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence
) {
errorMessage = errString.toString()
}
override fun onAuthenticationFailed() {
errorMessage = "Authentication failed. Try again."
}
}
)
biometricPrompt.authenticate(promptInfo)
}
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (isAuthenticated) {
Text(
text = "Welcome! You are authenticated.",
style = MaterialTheme.typography.headlineMedium
)
} else {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Authentication required",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { showBiometricPrompt() }) {
Text("Unlock with Biometrics")
}
errorMessage?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
Biometrics + Encrypted Storage
Combine biometrics with encrypted storage for maximum security. Authenticate first, then read the encrypted data:
class SecureVault(private val context: Context) {
private val encryptedPrefs = createEncryptedPrefs(context)
fun saveSecret(key: String, value: String) {
encryptedPrefs.edit().putString(key, value).apply()
}
fun getSecret(
key: String,
activity: FragmentActivity,
onSuccess: (String?) -> Unit,
onError: (String) -> Unit
) {
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Access Secure Data")
.setSubtitle("Authenticate to view sensitive information")
.setNegativeButtonText("Cancel")
.build()
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
val value = encryptedPrefs.getString(key, null)
onSuccess(value)
}
override fun onAuthenticationError(
errorCode: Int, errString: CharSequence
) {
onError(errString.toString())
}
}
BiometricPrompt(
activity,
ContextCompat.getMainExecutor(context),
callback
).authenticate(promptInfo)
}
}
SSL/TLS Pinning
SSL pinning prevents man-in-the-middle attacks by verifying the server’s certificate matches a known value.
With OkHttp CertificatePinner
val certificatePinner = CertificatePinner.Builder()
.add(
"api.example.com",
"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
)
.add(
"api.example.com",
"sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
)
.build()
val okHttpClient = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
Important: Always pin at least two certificates (primary and backup). If one expires and you only have one pin, your app stops working.
Network Security Configuration
For more control, use network_security_config.xml:
<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Disallow cleartext (HTTP) traffic -->
<base-config cleartextTrafficPermitted="false" />
<!-- Pin certificates for your API -->
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">AAAA...=</pin>
<pin digest="SHA-256">BBBB...=</pin>
</pin-set>
</domain-config>
<!-- Allow cleartext for debug only -->
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
Reference it in the manifest:
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
Play Integrity API
The Play Integrity API helps you verify that:
- The app is the genuine version from Google Play
- The device is a real device (not an emulator or rooted)
- The user has a valid Google Play license
This prevents unauthorized API access from modified APKs.
class IntegrityChecker(private val context: Context) {
suspend fun getIntegrityToken(): String? {
return try {
val integrityManager = IntegrityManagerFactory.create(context)
val request = IntegrityTokenRequest.builder()
.setNonce(generateNonce())
.build()
val response = integrityManager
.requestIntegrityToken(request)
.await()
response.token()
} catch (e: Exception) {
null
}
}
private fun generateNonce(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return Base64.encodeToString(bytes, Base64.NO_WRAP)
}
}
Send the token to your server. Your server verifies it with Google’s API and decides whether to trust the request.
Measuring APK Size
Before and after applying security measures, check your APK size:
APK Analyzer
In Android Studio: Build → Analyze APK
This shows:
- Total APK size and download size
- Size of each component (code, resources, native libraries)
- Which classes and methods take the most space
Command Line
# Build release APK
./gradlew assembleRelease
# Check size
ls -lh app/build/outputs/apk/release/app-release.apk
# Build AAB (for Play Store)
./gradlew bundleRelease
ls -lh app/build/outputs/bundle/release/app-release.aab
Mapping Files
Always upload mapping.txt to:
- Play Console — for readable crash reports in Android Vitals
- Firebase Crashlytics — for deobfuscated stack traces
# Find the mapping file
find app/build/outputs/mapping -name "mapping.txt"
Common Mistakes
1. Hardcoding API Keys
// Bad — visible after decompilation
const val API_KEY = "sk_live_abc123"
// Better — use BuildConfig from local.properties
val apiKey = BuildConfig.API_KEY
// Best — fetch from server after authentication
Even with R8, string constants are not obfuscated. Use BuildConfig fields loaded from local.properties (which is in .gitignore).
2. Disabling R8 for “Easier Debugging”
Some teams disable R8 in release builds because it causes issues. Fix the ProGuard rules instead. R8 is essential for both size and security.
3. Not Testing Release Builds
Your debug build works fine, but the release build crashes because R8 removed a class used by reflection. Test release builds on every PR.
4. Storing Sensitive Data in Plain Text
SharedPreferences are stored as XML files that can be read on rooted devices. Use EncryptedSharedPreferences for anything sensitive.
5. Pinning Only One Certificate
If your only pinned certificate expires, your app can’t connect to the server. Always pin at least a primary and backup certificate, and set an expiration date.
What’s Next?
In this tutorial, you learned:
- How R8 shrinks, obfuscates, and optimizes your code
- Writing ProGuard rules for common libraries
- Encrypting sensitive data with EncryptedSharedPreferences
- Adding biometric authentication with BiometricPrompt
- SSL/TLS pinning with OkHttp and network security config
- Verifying app integrity with Play Integrity API
- Measuring and reducing APK size
Next up: Android Tutorial #17: Firebase — Auth, Crashlytics, Analytics, Remote Config — where you will learn how to add authentication, crash reporting, and feature flags to your app.
Related Articles
- Android Tutorial #17: Firebase — upload mapping files to Crashlytics
- Android Tutorial #19: Play Store Publishing — APK requirements and signing
- Android Tutorial #6: Retrofit — API layer that benefits from R8
- Android Tutorial #8: DataStore — non-sensitive data storage
This is part 16 of the Android Development Tutorial series.