Why Mobile Security Requires Different Thinking
Secure Data Storage
// EncryptedSharedPreferences: drop-in secure replacement
private fun getSecurePrefs(
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
)
}
// Usage: identical to regular SharedPreferences
val prefs = getSecurePrefs(context)
prefs.edit()
.putString("auth_token", token)
.putString("refresh_token", refreshToken)
.apply()
// For tokens in memory: use char arrays, not Strings
// Strings are immutable and linger in the heap
class TokenHolder {
private var tokenChars: CharArray? = null
fun setToken(token: String) {
clearToken()
tokenChars = token.toCharArray()
}
fun getToken(): String? =
tokenChars?.let { String(it) }
fun clearToken() {
tokenChars?.fill('\u0000')
tokenChars = null
}
}Network Security: Certificate Pinning
<!-- 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="2027-01-01">
<pin digest="SHA-256">
base64EncodedSHA256HashOfPublicKey=
</pin>
<!-- Backup pin for rotation -->
<pin digest="SHA-256">
backupBase64EncodedSHA256Hash=
</pin>
</pin-set>
</domain-config>
<!-- Debug: allow local proxy for development -->
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>Input Validation and Injection Prevention
// VULNERABLE: SQL injection via string concatenation
@Query("SELECT * FROM users WHERE name = '${name}'")
fun findUserUnsafe(name: String): User // NEVER DO THIS
// SAFE: Room parameterized queries prevent injection
@Query("SELECT * FROM users WHERE name = :name")
fun findUser(name: String): User
// Deep link validation
fun handleDeepLink(uri: Uri) {
val userId = uri.getQueryParameter("userId")
// Validate format before using
if (userId == null ||
!userId.matches(Regex("^[a-zA-Z0-9]{1,36}$"))
) {
return // Reject malformed input
}
// Check for path traversal attempts
val fileName = uri.lastPathSegment ?: return
if (fileName.contains("..") ||
fileName.contains("/")
) {
return // Path traversal attempt
}
navigateToProfile(userId)
}Code Obfuscation with R8
// build.gradle.kts
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile(
"proguard-android-optimize.txt"
),
"proguard-rules.pro"
)
}
}
}
// proguard-rules.pro
# Keep data classes used with serialization
-keep class com.app.model.** { *; }
# Keep Retrofit service interfaces
-keep,allowobfuscation interface com.app.network.api.** {
@retrofit2.http.* <methods>;
}
# Keep Hilt-generated components
-keep class **_HiltModules* { *; }
-keep @dagger.hilt.android.lifecycle.HiltViewModel
class * { *; }
# Keep Room-generated code
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
# Crash reporting: keep line numbers
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFileRuntime Integrity Checks
// Play Integrity API: server-verified attestation
class IntegrityChecker @Inject constructor(
private val context: Context,
private val api: BackendApi,
) {
suspend fun verifyDeviceIntegrity(): Boolean {
val manager = IntegrityManagerFactory
.create(context)
val tokenRequest = IntegrityTokenRequest.builder()
.setNonce(generateNonce())
.build()
val tokenResponse = manager
.requestIntegrityToken(tokenRequest)
.await()
// Send token to YOUR server for verification
// Never verify on the client
val result = api.verifyIntegrity(
token = tokenResponse.token()
)
return result.deviceRecognized &&
result.appRecognized &&
result.accountLicensed
}
private fun generateNonce(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return Base64.encodeToString(
bytes, Base64.NO_WRAP
)
}
}Key Takeaways
- 1Assume the client device is compromised -- never trust client-side validation alone.
- 2Use EncryptedSharedPreferences and Android Keystore for sensitive data storage.
- 3Implement certificate pinning via network_security_config.xml with backup pins.
- 4Room parameterized queries prevent SQL injection -- never concatenate user input into queries.
- 5Enable R8 minification and obfuscation for release builds with proper keep rules.
- 6Use the Play Integrity API for server-verified device attestation.
Frequently Asked
How do I securely store API keys?
Don't embed API keys in the app. Use backend proxy for API calls. If unavoidable, store in EncryptedSharedPreferences with keys in Android Keystore. Understand that client-side secrets can always be extracted with enough effort.
Is certificate pinning worth it?
Yes for apps handling sensitive data. Certificate pinning prevents MITM attacks even if a CA is compromised. Implement pinning with backup pins and plan for key rotation.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.