Droid Manual // Android Knowledge Base

Droid Manual

Android Security Best Practices

October 29, 2026 · 9 min read

Introduction

Android security is not a single feature you toggle on—it is a layered discipline spanning the network, storage, authentication, and the operating system's permission model. A breach in any one layer can undermine everything built above it. This tutorial walks through the four pillars that every production Android app must address: securing network traffic with HTTPS and certificate pinning, protecting local data with the Android Keystore and EncryptedSharedPreferences, verifying user identity through biometrics, and requesting permissions correctly at runtime. Each section provides real, compilable code and explains the reasoning behind the patterns used.

Securing Network Traffic with HTTPS and Network Security Configuration

Transport Layer Security is the minimum requirement for any network request. However, simply using https:// is not enough. By default, Android allows a broad set of cipher suites and trusts system certificates, which means a compromised or misconfigured certificate authority can expose your traffic. The proper approach is to declare a network security configuration that restricts cleartext traffic, sets secure defaults, and optionally pins certificates.

<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="false">
        <domain includeSubdomains="true">api.yourdomain.com</domain>
        <domain includeSubdomains="true">cdn.yourdomain.com</domain>
        <trust-anchors>
            <certificates src="system" />
            <certificates src="user" overridePins="true" />
        </trust-anchors>
    </domain-config>
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
</network-security-config>

This XML configuration disables cleartext traffic for your domains and falls back to a restrictive base config for everything else. The <trust-anchors> element under the domain-specific config allows both system and user-installed certificates, while the base config permits only system certificates—this is the recommended pattern for apps that do not implement certificate pinning. User-installed certificates are explicitly excluded at the base level because they are a common vector for man-in-the-middle attacks on unpinning devices.

You must reference this file from your manifest so Android applies it globally.

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

The manifest attribute usesCleartextTraffic acts as a safety net. Even if your configuration XML has a gap, Android will block cleartext requests at the OS level. Always declare both—the manifest attribute and the XML config—because they enforce the policy at different layers. The manifest attribute is a binary switch; the XML configuration gives you granular control over individual domains and trust anchors.

Certificate Pinning

Certificate pinning goes a step further by binding your app to a specific public key or certificate. When the server presents a certificate whose public key does not match the pinned value, the connection is rejected immediately. This protects against attacks where a fraudulent certificate is issued by a compromised or rogue CA.

// build.gradle.kts (module level)
dependencies {
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("com.squareup.okhttp3:okhttp-sse:4.12.0")
}

You need OkHttp as the network layer because Android's built-in HttpsURLConnection does not support certificate pinning directly. OkHttp exposes a clean API for building a pinned client.

// OkHttpPinProvider.kt
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient

object OkHttpPinProvider {

    fun buildPinnedClient(): OkHttpClient {
        val certificatePinner = CertificatePinner.Builder()
            .add("api.yourdomain.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
            .add("api.yourdomain.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
            .build()

        return OkHttpClient.Builder()
            .certificatePinner(certificatePinner)
            .build()
    }
}

The sha256/... values are the Base64-encoded Subject Public Key Info (SPKI) hashes of your server's leaf certificate and its intermediate. To obtain them, run:

echo -n | openssl s_client -servername api.yourdomain.com -connect api.yourdomain.com:443 2>/dev/null | \
  openssl x509 -pubkey -noout 2>/dev/null | \
  openssl rsa pubkey -outform der 2>/dev/null | \
  openssl dgst -sha256 -binary | \
  openssl enc -base64

Rotate pins before deploying them by adding the old pin alongside the new one. If you add only the new pin and the server still presents the old certificate during a rollout window, every user will see connection failures. OkHttp logs a warning when a pin is about to expire, so monitor your crash reports and play console ANR/logs for CertificatePinnerException strings during transitions.

Encrypted Storage with Android Keystore and EncryptedSharedPreferences

Local storage on Android is accessible to any app with the right permission, and rooted devices expose even protected files. The Android Keystore system solves this by keeping cryptographic keys in a hardware-backed keystore that never exports the raw key material. Pairing Keystore with Google's Encryption libraries gives you transparent, authenticated encryption for both blobs and SharedPreferences.

// build.gradle.kts (module level)
dependencies {
    implementation("androidx.security:security-crypto:1.1.0-alpha06")
}

The security-crypto artifact bundles the underlying AES-GCM primitives and Keystore integration in a stable, AndroidX-maintained package.

// SecurityPrefs.kt
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys

object SecurityPrefs {

    private const val PREFS_NAME = "app_secure_prefs"

    fun getPreferences(context: Context) = EncryptedSharedPreferences.create(
        PREFS_NAME,
        MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
        context,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )
}

This creates an encrypted SharedPreferences instance backed by an AES-256-GCM master key stored in the hardware Keystore. The AES256_SIV scheme for key encryption and AES256_GCM for value encryption are the current recommended defaults in security-crypto. Every read and write is transparently encrypted and authenticated, so an attacker who extracts the file sees only ciphertext.

For larger or structured data, use the EncryptedFile API instead.

// EncryptedFileHelper.kt
import android.content.Context
import androidx.security.crypto.EncryptedFile
import androidx.security.crypto.MasterKeys
import java.io.File

object EncryptedFileHelper {

    fun writeSecret(context: Context, filename: String, data: ByteArray) {
        val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
        val file = File(context.filesDir, filename)
        val encryptedFile = EncryptedFile.Builder(
            file,
            context,
            masterKeyAlias,
            EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
        ).build()

        encryptedFile.openFileOutput(EncryptedFile.Mode.APPEND_TO_EXISTING).use { out ->
            out.write(data)
        }
    }

    fun readSecret(context: Context, filename: String): ByteArray {
        val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
        val file = File(context.filesDir, filename)
        val encryptedFile = EncryptedFile.Builder(
            file,
            context,
            masterKeyAlias,
            EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
        ).build()

        return encryptedFile.openFileInput().use { it.readBytes() }
    }
}

The HKDF_4KB scheme derives per-chunk keys from the master key, which limits the blast radius of a key compromise to a single 4 KB block. The APPEND_TO_EXISTING mode is used for writes so that partial writes do not corrupt the file; the API internally handles chunk boundaries safely. Always wrap the stream in a use block to guarantee the file handle is closed, which flushes the authenticated cipher state and prevents data corruption.

Biometric Authentication

Biometrics provide a frictionless way to verify the device owner without exposing passwords in your app. Android's BiometricPrompt API handles the dialog, the sensor fallback, and the threading model so you do not have to manage it yourself. It also integrates with the Keystore, allowing you to sign a challenge with a hardware-backed key after a successful unlock.

// build.gradle.kts (module level)
dependencies {
    implementation("androidx.biometric:biometric:1.2.0-alpha05")
}
// BiometricAuthManager.kt
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import java.util.concurrent.Executor

class BiometricAuthManager(
    private val context: Context,
    private val onSuccess: () -> Unit
) {
    private val executor: Executor = ContextCompat.getMainExecutor(context)

    fun authenticate(promptTitle: String, promptSubtitle: String, promptDescription: String) {
        val biometricPrompt = BiometricPrompt(
            context as androidx.fragment.app.FragmentActivity,
            executor,
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                    super.onAuthenticationSucceeded(result)
                    onSuccess()
                }

                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    super.onAuthenticationError(errorCode, errString)
                    // Handle errors such as lockout, hardware missing, or user cancellation
                }

                override fun onAuthenticationFailed() {
                    super.onAuthenticationFailed()
                    // Biometric did not match; allow retry
                }
            }
        )

        val info = BiometricPrompt.PromptInfo.Builder()
            .setTitle(promptTitle)
            .setSubtitle(promptSubtitle)
            .setDescription(promptDescription)
            .setAllowedAuthenticators(
                BiometricManager.Authenticators.BIOMETRIC_STRONG or
                        BiometricManager.Authenticators.DEVICE_CREDENTIAL
            )
            .build()

        biometricPrompt.authenticate(info)
    }

    fun isBiometricAvailable(): Boolean {
        return BiometricManager.from(context).canAuthenticate(
            BiometricManager.Authenticators.BIOMETRIC_STRONG
        ) == BiometricManager.BIOMETRIC_SUCCESS
    }
}

The BiometricPrompt constructor requires an Activity, not an Application or Context, because it needs a window token to display the system dialog. The executor runs callbacks on the main thread so you can update the UI directly from onAuthenticationSucceeded. Setting allowedAuthenticators to include DEVICE_CREDENTIAL gives users a fallback to their PIN, pattern, or password when biometric hardware is unavailable or the sensor fails—never skip this, or users without biometrics will see a silent error.

Before showing the prompt, always call isBiometricAvailable(). On devices with no biometric hardware, skipping this check causes the prompt to fail immediately with an ERROR_HW_NOT_PRESENT error, which looks like a bug to the user. The BiometricManager check also accounts for enrolled credentials, so it returns failure if the user has the hardware but no fingerprint or face data registered.

Runtime Permission Handling

Android's permission system controls access to sensitive user data such as camera, location, and contacts. Permissions declared in the manifest are not automatically granted—they must be requested at runtime for dangerous permissions. The modern approach uses the Activity Result API, which replaces the legacy onRequestPermissionsResult callback with a typed, lifecycle-aware launcher.

// build.gradle.kts (module level)
dependencies {
    implementation("androidx.activity:activity-ktx:1.9.3")
    implementation("androidx.fragment:fragment-ktx:1.8.5")
}
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
// PermissionHelper.kt
import android.content.pm.PackageManager
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat

class PermissionHelper(private val activity: AppCompatActivity) {

    private val requestPermissionLauncher = activity.registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { isGranted: Boolean ->
        // Handle the result through a callback or LiveData instead of hardcoding here
    }

    fun askCameraPermission() {
        when {
            ContextCompat.checkSelfPermission(
                activity,
                android.Manifest.permission.CAMERA
            ) == PackageManager.PERMISSION_GRANTED -> {
                // Already granted; proceed directly
            }
            activity.shouldShowRequestPermissionRationale(android.Manifest.permission.CAMERA) -> {
                // Show rationale UI before requesting
            }
            else -> {
                requestPermissionLauncher.launch(android.Manifest.permission.CAMERA)
            }
        }
    }

    fun isCameraPermissionGranted(): Boolean =
        ContextCompat.checkSelfPermission(
            activity,
            android.Manifest.permission.CAMERA
        ) == PackageManager.PERMISSION_GRANTED
}

registerForActivityResult ties the permission request to the Activity's lifecycle, so the callback is never invoked after the Activity is destroyed—a common source of crashes in the older callback-based approach. The three-branch when expression covers every state: already granted, needs a rationale, or needs a fresh request. Never skip the rationale branch; users who deny a permission once expect to understand why you are asking again, and Android requires you to show that explanation before re-requesting.

Always declare the permission in the manifest regardless of whether you request it at runtime. Omitting the <uses-permission> tag causes a SecurityException at the moment the restricted API is called, and the Play Store may reject your app during review for missing declarations.

Common Pitfalls

Even experienced engineers make predictable mistakes in Android security. One of the most common is storing secrets in SharedPreferences without encryption. A plain-text preference file is world-readable on non-rooted devices if the app targets API 23 or lower, and it is trivial to extract on rooted devices regardless of the target API. Always route sensitive values through EncryptedSharedPreferences or the Keystore.

Another frequent error is trusting user input in URL schemes. Opening a Uri constructed from server data with Intent.ACTION_VIEW can lead to intent hijacking. Validate the scheme, host, and path, and prefer deep links over arbitrary URLs.

Certificate pinning is often implemented with only one pin. If your CA rotates certificates and you have not added the new pin, every user loses connectivity until your next release. Maintain at least two pins and update them proactively.

Finally, many apps call BiometricPrompt.authenticate() without checking BiometricManager.canAuthenticate() first. This produces a confusing error dialog on devices without biometric hardware. Always gate the call behind the availability check and provide a credential fallback.

Conclusion

Securing an Android app requires deliberate choices at every layer. Network traffic must be encrypted and, where the threat model demands it, pinned to specific keys. Data at rest should rely on the Android Keystore through EncryptedSharedPreferences and EncryptedFile, never on homegrown cryptography. Biometric authentication reduces friction while maintaining strong identity verification when combined with hardware-backed key signing. Runtime permissions protect user data from accidental exposure, and the Activity Result API makes the request flow safe and testable. By applying these practices consistently and avoiding the common pitfalls outlined above, you build a foundation that resists both automated scanning and targeted attacks. Security is not a one-time checklist—it is an ongoing discipline that evolves as new vulnerabilities emerge and your app grows.