Android app security best practices (practical guide)

    Android app security best practices (practical guide) hero image

    Android App Security Best Practices

    A practical, copy-paste guide to shipping secure Android apps: protect secrets, harden network traffic, encrypt data at rest, and block common attacks before they reach production.

    Why Android security matters for vibe coders

    Android’s openness and huge device diversity are strengths, but they raise your risk surface. A typical mobile stack touches local storage, web APIs, third-party SDKs, CI/CD, and app stores. Each hop is an opportunity for leakage or tampering. The goal isn’t perfect security; it’s layered defenses that make exploitation expensive and detection fast. This guide gives you pragmatic steps that fit solo builders and small teams shipping at startup speed.

    • Limit blast radius: assume a single control will fail.
    • Automate checks in CI/CD so they run every commit.
    • Prefer platform primitives (Keystore, BiometricPrompt, Network Security Config) over custom crypto.

    Use the Android Keystore for secrets and keys

    Never store raw API tokens, refresh tokens, or encryption keys in strings, assets, resources, or the `.apk`/.`aab`. Derive per-device keys and keep private keys non-exportable via the Android Keystore. For symmetric data-at-rest encryption, generate a key material bound to secure hardware when available. When you must persist tokens, encrypt them before writing and gate decryption behind user auth.

    • Set `setUserAuthenticationRequired(true)` to protect on stolen devices.
    • Prefer hardware-backed keys (`isInsideSecureHardware`) when available.
    • Rotate keys on major releases; migrate old blobs on first run.

    Create a non-exportable AES key and encrypt

    val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
    val spec = KeyGenParameterSpec.Builder(
        "ptkd_aes_key",
        KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
    ).setBlockModes(KeyProperties.BLOCK_MODE_GCM)
     .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
     .setUserAuthenticationRequired(true)
     .setUserAuthenticationParameters(60, KeyProperties.AUTH_BIOMETRIC_STRONG)
     .setKeySize(256)
     .build()
    keyGen.init(spec)
    keyGen.generateKey()
    

    Gate sensitive flows with biometrics

    Use `BiometricPrompt` to require the user’s biometric or device credential before decrypting tokens or opening sensitive screens. Do not roll your own PIN UI. Treat biometrics as a gate, not as a credential you transmit to servers.

    • Fallback to device credential for better coverage.
    • Avoid biometric gates on every tap; use session windows (e.g., 60–120 seconds).

    Prompt for biometric before decrypting

    val promptInfo = BiometricPrompt.PromptInfo.Builder()
      .setTitle("Unlock secure data")
      .setSubtitle("Authenticate to continue")
      .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
      .build()
    BiometricPrompt(this, executor, callback).authenticate(promptInfo)
    

    Harden network traffic with TLS and pinning

    TLS is table stakes, but misconfigurations and MitM proxies are common. Use a strict Network Security Config to block cleartext. For high-risk endpoints, add certificate or public-key pinning in your HTTP client and rotate pins safely. Pair this with short-lived OAuth tokens and backend device attestation to limit token replay.

    • Use OkHttp’s CertificatePinner to pin *intermediate* or *public keys* and keep a backup pin.
    • Rotate pins before certificate changes to prevent bricking clients.

    Block cleartext with Network Security Config

    <network-security-config>
      <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
          <certificates src="system" />
        </trust-anchors>
      </base-config>
    </network-security-config>
    

    Store as little as possible, encrypt the rest

    Everything you store can leak in backups, logs, screenshots, and on rooted devices. Prefer ephemeral memory, cache invalidation on logout, and server-side sessions. When you do persist data, encrypt fields before writing to SharedPreferences, Room, or files and include integrity (AEAD, e.g., AES-GCM).

    Encrypt SharedPreferences values

    val masterKey = MasterKey.Builder(ctx)
      .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
      .build()
    val securePrefs = EncryptedSharedPreferences.create(
      ctx,
      "ptkd_secure_prefs",
      masterKey,
      EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
      EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )
    securePrefs.edit().putString("refresh_token", encToken).apply()
    

    Ask for the minimum permissions

    Every permission increases perceived risk and actual attack surface. Request runtime permissions only when a feature truly needs them and provide degraded behavior if denied. Declare only required permissions in `AndroidManifest.xml` and use foreground service types to narrow capabilities when applicable.

    • Don’t request storage access for simple file picks—use the platform picker.
    • Use `QUERY_ALL_PACKAGES` only with a strong, reviewed justification.

    WebView can become a remote code execution vector if JavaScript interfaces are exposed carelessly. Disable file access unless needed, restrict navigation to trusted hosts, and validate all deep links and app links. Never pass raw URLs from intents to WebView without allow-listing.

    Harden WebView defaults

    web.settings.javaScriptEnabled = false
    web.settings.allowFileAccess = false
    web.settings.domStorageEnabled = false
    web.webViewClient = object: WebViewClient() {
      override fun shouldOverrideUrlLoading(v: WebView?, r: WebResourceRequest?): Boolean {
        val host = r?.url?.host ?: return true
        return host !in setOf("ptkd.com", "api.ptkd.com")
      }
    }
    

    Defend code and detect tampering

    Obfuscation does not stop determined reverse engineers, but it raises the cost. Enable R8/ProGuard with shrinking and obfuscation, strip logs, and avoid reflective access where possible. Add basic root detection, debug detection, and tamper checks (signature verification) to decide when to reduce features, add friction, or deny service.

    • Verify signing certificate hash at runtime and compare to release hash.
    • Treat detection signals as risk scores, not hard kills to avoid false positives.

    ProGuard/R8 starter rules

    -dontwarn okhttp3.**
    -keep class okhttp3.** { *; }
    -keep class retrofit2.** { *; }
    -assumenosideeffects class android.util.Log { *; }
    

    Use Play Integrity / SafetyNet with server checks

    Device attestation helps distinguish genuine installs from emulators or tampered builds. Don’t evaluate attestation only on device; always send results to your backend and verify server-side with your own nonce. Combine integrity signals with rate-limits and token binding to curb abuse.

    Server-verified nonce flow (pseudo)

    # client: request nonce -> server
    # client: call Play Integrity with nonce
    # client: send JWS to server
    # server: verify JWS, check nonce, issuer/audience, verdict
    

    Secure build, CI/CD, and supply chain

    Most production breaches start with leaked credentials in CI or compromised dependencies. Lock down CI secrets, sign artifacts, and require code review for manifest and gradle changes. Pin dependency versions, scan SBOMs, and monitor known CVEs. Store keystores securely and sign from CI using short-lived credentials.

    • Generate and publish SBOM (CycloneDX) on each build.
    • Restrict CI runners to your org; never build from forks with write tokens.
    • Enable 2FA and hardware keys for all developer accounts.

    Gradle: enable R8 and minify release

    buildTypes {
      release {
        minifyEnabled true
        shrinkResources true
        proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
      }
    }
    

    Log safely and monitor

    Logs help you debug and detect abuse, but they must not contain secrets or PII. Strip verbose logs from release builds, redact tokens, and centralize server logs with alerting. Add client-side event breadcrumbs for security flows (auth failures, integrity verdicts) and ship them with user consent.

    • Set `minLogLevel` to WARN for release.
    • Hash identifiers (e.g., using SHA-256) before sending analytics.

    Checklist before you ship

    Use this quick pass for each release. Automate as many checks as possible so you don’t rely on memory.

    • No secrets or tokens in code, assets, or resources.
    • Keystore used for keys; data at rest encrypted with AEAD.
    • Network Security Config blocks cleartext; pins in place for sensitive APIs.
    • Runtime permissions requested just-in-time; no over-broad scopes.
    • WebView locked down; deep links validated against allow-list.
    • R8 enabled; logs stripped; signature/tamper checks working.
    • Play Integrity evaluated server-side; tokens are short-lived.
    • CI secrets locked; artifacts signed; SBOM generated.
    • Crash/abuse monitoring enabled; alerts wired.

    What to fix first if you’re behind

    Start with the highest risk, lowest effort wins: block cleartext, move secrets into Keystore, and encrypt tokens at rest. Next, harden WebView and add basic integrity checks. Finally, plan a pinning rollout with backup pins and rotate long-lived keys. This staged approach ships value quickly without freezing your roadmap.

    FAQs

    Is certificate pinning required for every app?

    No. It’s valuable for high-risk APIs (payments, admin actions) but requires operational discipline to rotate pins safely. If your team cannot manage rotations, focus on TLS hygiene, token hardening, and server-side detection first.

    How do I protect API keys in a mobile app?

    Avoid embedding long-lived secrets. Use short-lived access tokens from your backend, derive device-bound keys via Keystore, and gate decryption with biometrics. Moves like code obfuscation help, but architectural changes (no hardcoded keys) are the real fix.

    What about rooted devices?

    Assume some users run rooted or emulated environments. Detect and score risk rather than hard-blocking all access. Reduce feature set, require stronger signals (biometric, attestation), and throttle sensitive API actions.

    How can small teams keep up with security?

    Automate checks in CI (lint, dependency scanning, manifest diff), ship a short SBOM, run Play Integrity verification on the backend, and keep a release checklist. Revisit threat assumptions quarterly, not daily.


    Ship Android security without slowing down

    PTKD scans your app, flags risky configs, and gives copy-paste fixes for Keystore, pinning, WebView, and more.

    Start free scan

    Written by Laurens Dauchy

    Related articles