Android App Security: Permissions, Secure Storage, and Network Security
A comprehensive guide to Android application security: Android Keystore for secret storage, permission minimization, network security config, exported component risks, root detection, ProGuard obfuscation, and OWASP Mobile Top 10.
Android's open ecosystem and sideloading capability create a larger attack surface than iOS. Attackers reverse engineer APKs to extract secrets, exploit over-permissioned applications to access sensitive device data, and intercept network traffic from apps that skip certificate validation. This guide covers the concrete implementation steps for securing an Android application against the most common attack patterns.
Android Keystore: The Right Place for Secrets
Storing secrets in SharedPreferences, SQLite, or flat files is insecure — any app with root access or a backup extraction can read them. The Android Keystore System stores cryptographic keys in secure hardware (if available) where private key material never leaves the device.
Generating and Using a Key
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.GCMParameterSpec
object KeystoreHelper {
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "app_master_key"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val GCM_TAG_LENGTH = 128
fun generateKey() {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
keyStore.load(null)
if (keyStore.containsAlias(KEY_ALIAS)) return
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEYSTORE
)
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)
// Require user authentication for each use
.setUserAuthenticationRequired(true)
.setUserAuthenticationValidityDurationSeconds(30)
// Invalidate key if biometrics change
.setInvalidatedByBiometricEnrollment(true)
.build()
)
keyGenerator.generateKey()
}
fun encrypt(plaintext: ByteArray): Pair<ByteArray, ByteArray> {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
keyStore.load(null)
val secretKey = keyStore.getKey(KEY_ALIAS, null) as javax.crypto.SecretKey
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
val iv = cipher.iv
val ciphertext = cipher.doFinal(plaintext)
return Pair(iv, ciphertext)
}
fun decrypt(iv: ByteArray, ciphertext: ByteArray): ByteArray {
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
keyStore.load(null)
val secretKey = keyStore.getKey(KEY_ALIAS, null) as javax.crypto.SecretKey
val cipher = Cipher.getInstance(TRANSFORMATION)
val spec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
return cipher.doFinal(ciphertext)
}
}
For storing user credentials or tokens, use EncryptedSharedPreferences from the Jetpack Security library, which handles key management automatically:
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedPrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// Store a token securely
encryptedPrefs.edit()
.putString("auth_token", token)
.apply()
// Retrieve it
val token = encryptedPrefs.getString("auth_token", null)
Permission Minimization
Request only the permissions your app actually uses. Each permission is a potential data leakage vector and a reason for users to deny or uninstall.
Audit Your AndroidManifest.xml
<!-- WRONG: over-requesting permissions -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- BETTER: only what's truly needed, with maxSdkVersion constraints -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Storage permissions no longer needed on API 33+ -->
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- Use granular media permissions on API 33+ -->
<uses-permission
android:name="android.permission.READ_MEDIA_IMAGES"
android:minSdkVersion="33" />
Request permissions at the point of use, not at app launch:
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
openCamera()
} else {
showPermissionRationale()
}
}
fun onTakePhotoClicked() {
when {
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED -> openCamera()
shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) ->
showPermissionRationale()
else -> requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
Network Security Configuration
Android's Network Security Configuration (NSC) lets you declare which CAs to trust, enforce cleartext traffic restrictions, and set certificate pins — all without code changes.
res/xml/network_security_config.xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Production: only trust specific CAs, no cleartext allowed -->
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.myapp.com</domain>
<pin-set expiration="2026-12-31">
<!-- Primary pin: SHA-256 of the public key -->
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<!-- Backup pin: keep this if you rotate the primary cert -->
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
<trust-anchors>
<!-- Trust only specific CA, not the entire system store -->
<certificates src="@raw/my_ca_cert" />
</trust-anchors>
</domain-config>
<!-- Debug only: allow cleartext on emulator localhost -->
<debug-overrides>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</debug-overrides>
<!-- Base config: no cleartext for all other domains -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
Reference it in AndroidManifest.xml:
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
Getting the certificate pin:
# Extract the SHA-256 pin from a domain's certificate
openssl s_client -connect api.myapp.com:443 -servername api.myapp.com 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| base64
Exported Components Risk
Android components (Activities, Services, BroadcastReceivers, ContentProviders) are "exported" when they can be started by other apps on the device. Over-exporting components allows malicious apps to trigger unintended behavior.
<!-- WRONG: activity exported without requiring permission -->
<activity
android:name=".DeepLinkActivity"
android:exported="true" />
<!-- BETTER: only export if needed for deep links, with intent filter -->
<activity
android:name=".DeepLinkActivity"
android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="myapp.com"
android:pathPrefix="/open" />
</intent-filter>
</activity>
<!-- Internal services should never be exported -->
<service
android:name=".SyncService"
android:exported="false" />
In your deep-link handler, always validate the incoming URI:
class DeepLinkActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri = intent.data ?: run {
finish()
return
}
// Validate the host — attackers can craft malicious deep links
if (uri.host != "myapp.com") {
finish()
return
}
val path = uri.path ?: ""
// Route only to known paths
when {
path.startsWith("/open/product/") -> {
val productId = path.removePrefix("/open/product/")
if (productId.matches(Regex("[a-zA-Z0-9\\-]{1,64}"))) {
openProduct(productId)
}
}
else -> finish()
}
}
}
Root Detection
Root detection is not an unbreakable control — a sophisticated attacker with root can bypass any detection. But it raises the bar for casual reverse engineering and satisfies compliance requirements in financial and healthcare applications.
object RootDetection {
fun isRooted(): Boolean {
return checkForSuBinary()
|| checkForRootPackages()
|| checkForRWSystemPartition()
|| checkForDangerousProps()
}
private fun checkForSuBinary(): Boolean {
val paths = arrayOf(
"/system/bin/su",
"/system/xbin/su",
"/sbin/su",
"/data/local/xbin/su",
"/data/local/bin/su",
"/data/local/su"
)
return paths.any { java.io.File(it).exists() }
}
private fun checkForRootPackages(): Boolean {
val knownRootPackages = listOf(
"com.noshufou.android.su",
"com.thirdparty.superuser",
"eu.chainfire.supersu",
"com.topjohnwu.magisk"
)
val pm = context.packageManager
return knownRootPackages.any { pkg ->
try { pm.getPackageInfo(pkg, 0); true } catch (e: Exception) { false }
}
}
private fun checkForRWSystemPartition(): Boolean {
return try {
val process = Runtime.getRuntime().exec(arrayOf("mount"))
val lines = process.inputStream.bufferedReader().readLines()
lines.any { it.contains("/system") && it.contains("rw,") }
} catch (e: Exception) { false }
}
private fun checkForDangerousProps(): Boolean {
return try {
val process = Runtime.getRuntime().exec(arrayOf("getprop"))
val output = process.inputStream.bufferedReader().readText()
output.contains("ro.debuggable=1") || output.contains("ro.secure=0")
} catch (e: Exception) { false }
}
}
ProGuard and R8 Code Obfuscation
ProGuard/R8 is enabled by default in release builds. Configure it to protect sensitive class names and methods:
// build.gradle
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
}
# proguard-rules.pro
# Keep entry points
-keep class com.myapp.MainActivity { *; }
# Keep data model classes used with serialization
-keep class com.myapp.models.** { *; }
# Obfuscate everything else, especially security-sensitive classes
# Do NOT add -keep for security-critical logic
# Remove logging in release
-assumenosideeffects class android.util.Log {
public static int d(...);
public static int v(...);
public static int i(...);
}
OWASP Mobile Top 10 Quick Reference
The OWASP Mobile Application Security Verification Standard (MASVS) defines the comprehensive standard. The Top 10 high-priority issues for Android:
- M1: Improper Credential Usage — API keys in APK, hardcoded passwords. Mitigate: no secrets in code, use Keystore.
- M2: Inadequate Supply Chain Security — Vulnerable third-party SDKs. Mitigate: dependency scanning with Gradle's dependency-check plugin.
- M3: Insecure Authentication/Authorization — Broken auth checks. Mitigate: verify tokens server-side, never client-side.
- M4: Insufficient Input/Output Validation — SQLite injection, intent injection. Mitigate: parameterized queries, intent validation.
- M5: Insecure Communication — HTTP, certificate mismatches. Mitigate: Network Security Config, certificate pinning.
- M6: Inadequate Privacy Controls — Sensitive data in logs, screenshots. Mitigate:
FLAG_SECUREon sensitive screens, no PII in logs. - M7: Insufficient Binary Protections — No obfuscation, easy to decompile. Mitigate: R8/ProGuard, app integrity checks (Play Integrity API).
- M8: Security Misconfiguration — Exported components, backup enabled. Mitigate:
android:allowBackup="false", restrict exports. - M9: Insecure Data Storage — Data in SharedPreferences, logs, world-readable files. Mitigate: EncryptedSharedPreferences, scoped storage.
- M10: Insufficient Cryptography — Weak algorithms, hardcoded keys. Mitigate: Android Keystore, AES-256-GCM.
Use the FLAG_SECURE window flag for screens that display sensitive information — it prevents screenshots and recent-apps thumbnails:
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
Run static analysis with MobSF or Semgrep's Android ruleset as part of CI to catch common issues automatically before code review.