---
key: "android-secure-coding"
title: "Harden an Android app's storage, network and entry points"
url: "https://softwaresecurity.com/domains/appsec/platform-security/android-secure-coding/"
api: "https://softwaresecurity.com/api/v1/articles/android-secure-coding.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-312","CWE-319","CWE-295","CWE-926","CWE-927","CWE-749","CWE-939","CWE-532","A01:2025","A02:2025","A04:2025","A07:2025","A08:2025"]
tags: ["Key management","Mobile apps","Java"]
stages: ["build"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Harden an Android app's storage, network and entry points

Encrypt local secrets with Android Keystore keys now that Jetpack Security is deprecated, set transport policy in Network Security Configuration, close exported components and mutable PendingIntents, confine WebViews, verify App Links and judge Play Integrity verdicts on the server, mapped to OWASP MASVS 2.1.

Canonical URL: https://softwaresecurity.com/domains/appsec/platform-security/android-secure-coding/

## In short

Encrypt secrets with a non-exportable AES-256-GCM Android Keystore key and keep the ciphertext out of backups; `EncryptedSharedPreferences` is deprecated. Leave cleartext off in Network Security Configuration, turn on Certificate Transparency, and pin only keys you can rotate. Declare `android:exported` explicitly, make `PendingIntent`s explicit and `FLAG_IMMUTABLE`, keep WebView JavaScript and file access off unless needed, verify App Links and validate every parameter. Judge Play Integrity verdicts on your server.

- Jetpack Security deprecated every API in 1.1.0-alpha07 (April 2025), and 1.1.0 (July 2025) is its last release: write your own Keystore-backed encryption.
- Keystore keys can't be exported, so data encrypted with them can't be decrypted after a restore to a new device. Keep that ciphertext out of backups.
- A `BiometricPrompt` callback proves nothing to your server. A signature from a key that needs biometric authentication for every use does.
- Certificate Transparency is on by default for apps targeting API 37 and opt-in for API 36; expired pins stop being enforced.
- Since API 34, creating a mutable `PendingIntent` around an implicit `Intent` throws. Use explicit, immutable intents.
- Decode Play Integrity tokens on your server, bind them to the request with `requestHash`, and never cache a verdict.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| `targetSdkVersion` | 36 or higher; 37 recommended | Google Play requires API 36 for new apps and updates since 31 August 2026, with extensions to 1 November. API 37 also turns Certificate Transparency on by default. | Play Console Help: target API level requirements |
| Secret storage | AES-256-GCM key in `AndroidKeyStore`; ciphertext in `noBackupFilesDir` | Keep randomized encryption on (the default) so Keystore picks every IV. | KeyGenParameterSpec.Builder reference |
| Step-up signing key | `setUserAuthenticationRequired(true)`, `setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG)`, `setInvalidatedByBiometricEnrollment(true)` | Use through `BiometricPrompt.CryptoObject`. Creation fails until a Class 3 biometric is enrolled. | KeyGenParameterSpec.Builder reference |
| `android:dataExtractionRules` | Exclude tokens, encrypted blobs and caches from `cloud-backup` and `device-transfer` | Keep `android:fullBackupContent` with the same excludes for Android 11 and lower. | Android Auto Backup guide |
| `cleartextTrafficPermitted` | `false` | Default for apps targeting API 28 and higher. Never set `true` in `base-config`. | Network Security Configuration |
| `trust-anchors` | `<certificates src="system" />` | Default for API 24 and higher. Put user-added CAs only in `debug-overrides`. | Network Security Configuration |
| `certificateTransparency` | `enabled="true"` | Default when targeting API 37, opt-in on API 36, unavailable on API 35 and lower. | Network Security Configuration |
| `android:exported` | `false` unless another app must start the component | Must be explicit since API 31 on components with intent filters. Providers default to `false` for API 17 and higher. | Android 12 behavior changes |
| PendingIntent flags | `FLAG_IMMUTABLE`, plus `FLAG_ONE_SHOT` for one-time actions | Wrap an explicit `Intent`. Use `FLAG_MUTABLE` only for inline reply or bubbles. | PendingIntent reference |
| WebView settings | `javaScriptEnabled` false unless needed; `allowFileAccess` false; `allowContentAccess` false | Serve bundled pages with `WebViewAssetLoader` instead of `file://` URLs. | Android app risks: WebView unsafe file inclusion |
| `android:autoVerify` | `true` on https intent filters for your domains | Serve `/.well-known/assetlinks.json` over HTTPS as `application/json`, with no redirects. | Android App Links documentation |
| `FLAG_SECURE` | Set on windows that show secrets | For apps targeting API 37, `setContentCaptureEnabled(false)` no longer turns off content capture; `FLAG_SECURE` does. | Android 17 behavior changes |

## Verify it

1. Dump the release manifest with `aapt2 dump xmltree` and confirm every exported component is meant to be public, `android:debuggable` is absent, and the network and backup rules are referenced.
2. From a normal `adb shell` (not `adb root`), start each non-exported activity with `am start -n` and expect `Permission Denial ... not exported`; send hostile URIs to every exported link handler.
3. After installing a store build, run `adb shell pm get-app-links` on the package and confirm every domain reports `verified`.
4. Route a release build through an interception proxy whose CA is installed as a user CA: every connection must fail, and pinned hosts must fail with `Pin verification failed`.
5. Feed the server's Play Integrity decision code replayed, stale, re-signed and wrong-request tokens and confirm each one is refused or stepped up.
6. Restore a backup to a second device and confirm the app signs the user out cleanly instead of crashing on data it can no longer decrypt.
7. Run the Android project's Robolectric tests and Android Lint: only the launcher and the link handler may be exported, a link must never pay or launch an `Intent` from its extras, release builds must refuse cleartext and user CAs, and no file may hold the token in plain text.

## What you will learn

- Encrypt local secrets with Keystore keys and plan the move off Jetpack Security.
- Configure Network Security Configuration, including Certificate Transparency and, when justified, pinning.
- Close exported components, PendingIntents, WebView bridges and deep links to untrusted callers.
- Use biometric-bound keys and Play Integrity as server-verified signals, mapped to MASVS 2.1.

Audience: Android developers and mobile AppSec reviewers who ship or assess Kotlin apps

Before you start:

- Read Kotlin and AndroidManifest.xml.
- Know how intents, activities and the Android Keystore fit together.
- Understand TLS certificate validation at a basic level.

Time: About 37 minutes: 11 to read and 26 for the exercise

## Map each MASVS 2.1 control to an Android mechanism

OWASP MASVS 2.1.0 is still the current release, and MASTG 2.0 (June 2026) links controls to MASWE weaknesses and the tests for them, though not every control has a test yet. Choose the depth with the MAS testing profiles. L1 treats other apps on the device as the adversary. L2 assumes the operating system can't be trusted and the attacker may hold the phone. R adds resistance to a user who reverse engineers the app. The table lists the Android mechanism this guide uses for each control; /domains/appsec/platform-security/android-ios-security/ pairs each with its iOS equivalent, and /domains/appsec/platform-security/ios-secure-coding/ covers the iOS side in depth.

| Control | Requirement | Android mechanism |
| --- | --- | --- |
| MASVS-STORAGE-1 | Store sensitive data securely | App-private storage, encrypted with an Android Keystore key |
| MASVS-STORAGE-2 | Prevent leaks | `dataExtractionRules`, `noBackupFilesDir`, release log stripping, `FLAG_SECURE` |
| MASVS-CRYPTO-1, CRYPTO-2 | Strong cryptography and key management | AES-256-GCM and P-256 ECDSA with non-exportable Keystore keys |
| MASVS-AUTH-2, AUTH-3 | Local authentication and step-up | `BiometricPrompt` with a `CryptoObject` over an auth-bound key |
| MASVS-NETWORK-1 | Secure all traffic | Network Security Configuration: no cleartext, system CAs, Certificate Transparency |
| MASVS-NETWORK-2 | Identity pinning (L2) | NSC `pin-set` with a backup pin and an expiry |
| MASVS-PLATFORM-1 | Secure IPC | Explicit `android:exported`, signature permissions, immutable `PendingIntent`s |
| MASVS-PLATFORM-2 | Secure WebViews | JavaScript and file access off, `WebViewAssetLoader`, origin-bound message listener |
| MASVS-CODE-1, CODE-4 | Current platform, validated input | `targetSdk` 36 or higher, strict parsing of links and extras |
| MASVS-RESILIENCE-1, -2 | Platform and app integrity | Play Integrity verdicts judged on the server |

## Encrypt secrets with a Keystore key

Keep secrets in app-specific internal storage, never in shared or external storage, and encrypt anything that works as a credential. The Android Keystore keeps key material out of your process: the app passes data to a system service that runs the operation, and on devices with a TEE or StrongBox the key never leaves secure hardware. A compromised app can use its keys while it runs, but it can't copy them off the device.

Generate an AES-256 key for GCM and let Keystore choose every IV. Randomized encryption is the default, and with it Keystore rejects a caller-supplied IV, which rules out nonce reuse. `KeyInfo.getSecurityLevel()` (API 31 and later) reports whether a key lives in software, the TEE or StrongBox; emulators report software. StrongBox (`setIsStrongBoxBacked`) is slower and supports fewer algorithms, and Google says most apps don't need it.

```kotlin
package com.example.bank

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.SecretKey
import javax.crypto.spec.GCMParameterSpec

// AES-256-GCM with a non-exportable Android Keystore key. Store the output in
// context.noBackupFilesDir, which Auto Backup never copies.
object TokenCipher {
    private const val ALIAS = "session-token-key"
    private const val TRANSFORMATION = "AES/GCM/NoPadding"
    private const val IV_BYTES = 12

    private fun key(): SecretKey {
        val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
        (keyStore.getKey(ALIAS, null) as? SecretKey)?.let { return it }
        val spec = KeyGenParameterSpec.Builder(
            ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
            .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            .setKeySize(256)
            // Randomized encryption stays on (the default): Keystore picks every IV.
            .build()
        return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
            .apply { init(spec) }
            .generateKey()
    }

    fun encrypt(plaintext: ByteArray): ByteArray {
        val cipher = Cipher.getInstance(TRANSFORMATION)
        cipher.init(Cipher.ENCRYPT_MODE, key())        // no IV argument
        return cipher.iv + cipher.doFinal(plaintext)    // IV, then ciphertext and 16-byte tag
    }

    fun decrypt(blob: ByteArray): ByteArray {
        val cipher = Cipher.getInstance(TRANSFORMATION)
        cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, blob, 0, IV_BYTES))
        return cipher.doFinal(blob, IV_BYTES, blob.size - IV_BYTES)
    }
}
```

## Migrate off EncryptedSharedPreferences

Jetpack Security (`androidx.security:security-crypto`) deprecated all its APIs, including `EncryptedSharedPreferences`, `EncryptedFile` and `MasterKey`, in 1.1.0-alpha07 in April 2025. Version 1.1.0, released on 30 July 2025, is the final one; Android's documentation says no further releases will follow and points to platform APIs and direct use of the Keystore. Its reference pages name the replacements: `SharedPreferences` for `EncryptedSharedPreferences`, `java.io.File` for `EncryptedFile`, and `KeyGenerator` with an `AndroidKeyStore` instance for `MasterKey`. Existing data keeps working, but the library won't get fixes.

Migrate once, on the first launch after the update. Read each value through the old API, encrypt it with your own Keystore key, write it to DataStore or a file, then delete the old preferences file. Exclude the new ciphertext from backup and device transfer. Keystore keys never leave the device, so a blob restored onto a new phone can't be decrypted: catch the failure, delete the blob and send the user back through sign-in.

## Bind step-up authentication to a key, not a callback

A `BiometricPrompt` callback that only sets a flag can be skipped by anyone who hooks the app, and it proves nothing to your server. MASVS-AUTH-2 and AUTH-3 need authentication with a cryptographic effect. Create a Keystore key that requires authentication for every use (timeout 0) with `AUTH_BIOMETRIC_STRONG`, pass an initialized `Signature` or `Cipher` to `BiometricPrompt` in a `CryptoObject`, and use it only inside `onAuthenticationSucceeded`.

For high-value actions, sign a one-time server challenge with a P-256 key and register its public key and attestation chain at enrollment. The server verifies the signature, so a tampered client gains nothing. Creating the key fails until the user has enrolled a biometric, and any key that needs authentication fails without a secure lock screen. With `setInvalidatedByBiometricEnrollment(true)`, the default, adding a fingerprint permanently invalidates the key; re-enroll after a full sign-in.

```kotlin
package com.example.bank

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.PrivateKey
import java.security.Signature
import java.security.spec.ECGenParameterSpec

private const val STEP_UP_ALIAS = "step-up-signing-key"

// Enrollment: a P-256 key that can sign only right after a Class 3 biometric match.
// Send the public key and its attestation chain to the server.
fun createStepUpKey(attestationChallenge: ByteArray): KeyPair =
    KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore").run {
        initialize(
            KeyGenParameterSpec.Builder(STEP_UP_ALIAS, KeyProperties.PURPOSE_SIGN)
                .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
                .setDigests(KeyProperties.DIGEST_SHA256)
                .setUserAuthenticationRequired(true)
                .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
                .setInvalidatedByBiometricEnrollment(true)
                .setAttestationChallenge(attestationChallenge)
                .build())
        generateKeyPair()
    }

// Step-up: the server trusts the signature over its one-time challenge, not a callback.
fun signChallenge(activity: FragmentActivity, challenge: ByteArray, onSigned: (ByteArray) -> Unit) {
    val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    val signature = Signature.getInstance("SHA256withECDSA")
    // Throws KeyPermanentlyInvalidatedException after a biometric change: re-enroll.
    signature.initSign(keyStore.getKey(STEP_UP_ALIAS, null) as PrivateKey)

    val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity),
        object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                val unlocked = result.cryptoObject?.signature ?: return
                unlocked.update(challenge)
                onSigned(unlocked.sign())   // the server verifies with the enrolled public key
            }
        })
    val info = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Confirm transfer")
        .setAllowedAuthenticators(BIOMETRIC_STRONG)
        .setNegativeButtonText("Cancel")
        .build()
    prompt.authenticate(info, BiometricPrompt.CryptoObject(signature))
}
```

## Keep data out of backups, logs, screenshots and the clipboard

Auto Backup copies almost all app data to the user's Google Drive and to new phones. Apps targeting API 31 or higher use `dataExtractionRules` on Android 12 and later, with separate cloud-backup and device-transfer sections; a missing section allows everything. For those apps, `android:allowBackup="false"` doesn't stop device-to-device transfer on some manufacturers' devices, so exclude secrets explicitly. Files in `noBackupFilesDir` and the cache directories are never backed up.

- Strip verbose, debug and info logging from release builds with R8 `-assumenosideeffects` rules, and never log tokens at any level.
- Set `FLAG_SECURE` on windows that show secrets. It keeps them out of screenshots and off non-secure displays, and for apps targeting API 37 it's the only way to opt out of content capture.
- Mark copied secrets with `ClipDescription.EXTRA_IS_SENSITIVE` (API 33 and later) so keyboard clipboard previews hide them.
- Keep confirmation buttons from acting on touches that arrive through an overlay with `setFilterTouchesWhenObscured(true)`.

```xml
<?xml version="1.0" encoding="utf-8"?>
<!-- res/xml/data_extraction_rules.xml, referenced by android:dataExtractionRules (Android 12+).
     Keep android:fullBackupContent with the same excludes for Android 11 and lower. -->
<data-extraction-rules>
    <cloud-backup disableIfNoEncryptionCapabilities="true">
        <exclude domain="sharedpref" path="session.xml" />
        <exclude domain="database" path="cache.db" />
    </cloud-backup>
    <!-- Leaving this section out would allow everything during device-to-device transfer -->
    <device-transfer>
        <exclude domain="sharedpref" path="session.xml" />
        <exclude domain="database" path="cache.db" />
    </device-transfer>
</data-extraction-rules>
```

## Set transport policy in Network Security Configuration

Network Security Configuration is the declarative home for transport policy. Since API 28, cleartext is off by default; since API 24, user-installed CAs aren't trusted; apps targeting Android 15 can't use TLS 1.0 or 1.1. Certificate Transparency arrived in Android 16: it's opt-in for apps targeting API 36 and on by default for API 37, and it's skipped on connections that use custom trust anchors. Android 17 adds Encrypted Client Hello through a `domainEncryption` element.

Code that installs its own `TrustManager`, or opens raw sockets, bypasses the configuration. Never ship a `TrustManager` that accepts every certificate, a `HostnameVerifier` that returns `true`, or a `WebViewClient.onReceivedSslError` that calls `proceed()`. Put debugging CAs in `debug-overrides`, which apply only when the app is debuggable.

```xml
<?xml version="1.0" encoding="utf-8"?>
<!-- res/xml/network_security_config.xml, referenced by android:networkSecurityConfig -->
<network-security-config>
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
        <!-- On by default when targeting API 37; needed on API 36 -->
        <certificateTransparency enabled="true" />
    </base-config>
    <!-- Applies only when android:debuggable is true, so release builds ignore it -->
    <debug-overrides>
        <trust-anchors>
            <certificates src="user" />
        </trust-anchors>
    </debug-overrides>
</network-security-config>
```

## Pin only keys you can rotate

Android's documentation recommends against certificate pinning, because a CA or key change strands every installed copy until users update. OWASP places pinning (MASVS-NETWORK-2) in the L2 profile, not the L1 baseline. Pin when a mis-issued public certificate is a real threat, as for banking, health or government apps, and when you control which certificates your servers use.

Pin SubjectPublicKeyInfo hashes of an issuing CA or of keys you hold, never only today's leaf. Include a backup key that's fully in your control, and set an expiration date: once it passes, Android stops checking the pins and falls back to normal validation. Pinning doesn't stop someone who controls the device, because they can hook the TLS stack; that threat belongs to integrity checks and server-side controls.

```xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config>
        <domain includeSubdomains="false">api.bank.example.com</domain>
        <!-- After this date Android stops checking the pins: ship new ones before it. -->
        <pin-set expiration="2027-06-30">
            <!-- SPKI SHA-256 of the issuing CA in use today -->
            <pin digest="SHA-256">44viFzTC+h/L+3OHRg4Rs5v4+AcpzHZvI9Tne2RDNGk=</pin>
            <!-- Backup: a CA or key you could switch to without an app update -->
            <pin digest="SHA-256">9C7mf4J789KvLX59lcMyYpsH6bpdmoAGTByZNhcusLA=</pin>
        </pin-set>
    </domain-config>
</network-security-config>
<!-- Compute a pin from a PEM certificate:
openssl x509 -in ca.pem -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64 -->
```

## Export only the components other apps must reach

Every activity, service, receiver and provider that another app can start is an entry point. Apps targeting API 31 or higher must declare `android:exported` on components with intent filters, or the build fails. Set it to `false` unless another app genuinely needs the component. Since API 34, implicit intents reach only exported components, so internal flows need explicit intents.

Protect components shared between your own apps with a permission whose `protectionLevel` is `signature`, and treat every extra as attacker-controlled. Never start an `Intent` unparceled from another app's extras: that intent redirection hands the caller your private components and URI grants. If you must forward one, check where it resolves and clear URI grant flags, or rebuild it with `IntentSanitizer` from `androidx.core`. Android 16 hardens redirection by default; don't call `removeLaunchSecurityProtection()`.

```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.bank">
    <permission android:name="com.example.bank.permission.SYNC"
        android:protectionLevel="signature" />
    <application>
        <!-- Reachable from other apps and the browser: verified links only -->
        <activity android:name=".InvoiceLinkActivity" 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="bank.example.com"
                    android:pathPrefix="/invoices/" />
            </intent-filter>
        </activity>
        <!-- Internal: other apps can't start it, even by naming it -->
        <activity android:name=".TransferReviewActivity" android:exported="false" />
        <!-- Shared only with apps signed with the same key -->
        <service android:name=".SyncService" android:exported="true"
            android:permission="com.example.bank.permission.SYNC" />
    </application>
</manifest>
```

## Make PendingIntents explicit and immutable

A `PendingIntent` lets a notification, alarm or widget host act with your app's identity. If it's mutable, the holder can fill in empty fields such as the component and reach your non-exported activities. Apps targeting API 31 or higher must pass `FLAG_IMMUTABLE` or `FLAG_MUTABLE`, and since API 34 a mutable `PendingIntent` around an implicit `Intent` throws. Use `FLAG_MUTABLE` only for inline reply or bubbles, and add `FLAG_ONE_SHOT` where replay would hurt.

```kotlin
package com.example.bank

import android.app.PendingIntent
import android.content.Context
import android.content.Intent

// Vulnerable: mutable and implicit. The holder can fill in a component and launch it
// with your app's identity. Apps targeting API 34+ get IllegalArgumentException instead.
fun badReminder(context: Context): PendingIntent =
    PendingIntent.getActivity(context, 0, Intent("com.example.bank.REVIEW_TRANSFER"),
        PendingIntent.FLAG_MUTABLE)

// Fixed: explicit component, immutable, usable once.
fun reminder(context: Context, transferId: String): PendingIntent {
    val intent = Intent(context, TransferReviewActivity::class.java)
        .putExtra("transferId", transferId)
    return PendingIntent.getActivity(context, transferId.hashCode(), intent,
        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT)
}
```

## Confine WebViews to content you control

WebView defaults are safe on current targets: JavaScript is off, file access is off for apps targeting API 30 or higher, mixed content is blocked and Safe Browsing is on. Content access stays on until you turn it off. Serve bundled pages through `WebViewAssetLoader` on `https://appassets.androidplatform.net` instead of `file://` URLs; `allowFileAccessFromFileURLs` and `allowUniversalAccessFromFileURLs` were deprecated in API 30.

`addJavascriptInterface` injects an object into every frame, third-party iframes included, and can't tell you which frame called. Use `WebViewCompat.addWebMessageListener` with an exact HTTPS origin, check `isMainFrame`, and map a fixed set of messages to actions. Keep navigation on your own origin and send other links to the browser. Use Custom Tabs, not a WebView, for sign-in with another provider, as RFC 8252 requires.

```kotlin
package com.example.bank

import android.app.Activity
import android.content.Intent
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewClientCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature

private const val HOST = "appassets.androidplatform.net"

// Bundled help pages served over https:// by WebViewAssetLoader instead of file://.
fun WebView.showHelp(activity: Activity) {
    val assets = WebViewAssetLoader.Builder()
        .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(activity))
        .build()
    settings.javaScriptEnabled = true     // off by default; the help pages need it
    settings.allowFileAccess = false      // false by default only for targetSdk 30+
    settings.allowContentAccess = false

    webViewClient = object : WebViewClientCompat() {
        override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? =
            assets.shouldInterceptRequest(request.url)

        override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
            val url = request.url
            if (url.scheme == "https" && url.host == HOST) return false
            if (url.scheme == "https") activity.startActivity(Intent(Intent.ACTION_VIEW, url))
            return true   // never load other origins in this WebView
        }
        // Don't override onReceivedSslError to call proceed(); let TLS errors fail.
    }

    // Unlike addJavascriptInterface, the bridge exists only in frames from this exact origin.
    if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
        WebViewCompat.addWebMessageListener(this, "bankBridge", setOf("https://$HOST")) { _, message, _, isMainFrame, _ ->
            if (isMainFrame && message.data == "openSettings") activity.startActivity(Intent(android.provider.Settings.ACTION_SETTINGS))
        }
    }
    loadUrl("https://$HOST/assets/help/index.html")
}
```

## Verify App Links and validate every parameter

Any app can register your custom scheme or an unverified https intent filter. Since Android 12, a web link opens your app only if you've verified the domain or the user has approved it; otherwise it goes to the browser. Add `android:autoVerify="true"` and serve `/.well-known/assetlinks.json` over HTTPS, as `application/json`, with no redirects. With Play App Signing, copy the SHA-256 fingerprint from Play Console. On Android 15 and later devices with Google services, dynamic rules in that file can add or exclude paths without an app update.

Verification only proves that the domain routes to your app. Parse each link strictly, accept one operation, and open a screen that does nothing until the user confirms; the server still checks that the user owns the invoice. Compare parsed fields rather than string prefixes, and match the decoded path in full so `%2F` and `%0A` can't slip through.

```kotlin
package com.example.bank

import android.net.Uri

private val INVOICE_PATH = Regex("/invoices/([A-Z0-9]{8,20})")

// The only link this app accepts: https://bank.example.com/invoices/<id>
fun invoiceIdFrom(uri: Uri?): String? {
    if (uri == null || uri.scheme != "https" || uri.host != "bank.example.com") return null
    if (uri.port != -1 || uri.userInfo != null || uri.query != null || uri.fragment != null) return null
    // uri.path is percent-decoded, so %2F and %0A can't slip past the full-path match.
    return INVOICE_PATH.matchEntire(uri.path ?: return null)?.groupValues?.get(1)
}
// In the activity: open a read-only invoice screen for that id. Paying still goes
// through the normal confirmation flow, and the server checks the user owns the invoice.
```

## Judge Play Integrity verdicts on your server

Root and tamper checks inside the app run on the attacker's device and can be patched out. Play Integrity (MASVS-RESILIENCE-1 and -2) returns a token that your server decodes through Google; SafetyNet Attestation was turned down in January 2025. Prefer standard requests, which take a few hundred milliseconds and are protected from replay, and put a hash of the request in `requestHash` so a verdict can't be reused for another action. The default quota is 10,000 requests a day.

Check `requestDetails` first, then require `PLAY_RECOGNIZED`, and send `UNLICENSED` installs to step-up or Play's remediation dialog. Device labels are tiers: on Android 13 and later `MEETS_STRONG_INTEGRITY` needs a security update from the last year, `MEETS_DEVICE_INTEGRITY` means a genuine certified device and `MEETS_BASIC_INTEGRITY` is the weakest. Don't cache verdicts, return graded outcomes rather than allow or deny, and treat a missing label as a reason for step-up rather than proof of fraud.

```js
import { createHash } from 'node:crypto';

// The app sends requestHash = SHA-256 of the same canonical request it is about to make.
export const requestHash = (canonicalRequest) =>
  createHash('sha256').update(canonicalRequest).digest('base64url');

// Decode on Google's server; never let the app evaluate its own verdict.
export async function decodeToken(token, packageName, accessToken) {
  const res = await fetch(
    `https://playintegrity.googleapis.com/v1/${packageName}:decodeIntegrityToken`,
    { method: 'POST',
      headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ integrityToken: token }) });
  if (!res.ok) throw new Error(`decode failed: ${res.status}`);
  return (await res.json()).tokenPayloadExternal;
}

// Returns a decision tier, not a yes/no answer.
export function decide(p, { packageName, canonicalRequest, now = Date.now(), maxAgeMs = 60_000 }) {
  const req = p.requestDetails ?? {};
  if (req.requestPackageName !== packageName) return 'deny';
  if (req.requestHash !== requestHash(canonicalRequest)) return 'deny';      // bound to this request
  if (now - Number(req.timestampMillis) > maxAgeMs) return 'deny';           // stale token
  if (p.appIntegrity?.appRecognitionVerdict !== 'PLAY_RECOGNIZED') return 'deny';
  if (p.appIntegrity?.packageName !== packageName) return 'deny';
  if (p.accountDetails?.appLicensingVerdict !== 'LICENSED') return 'step-up';
  const labels = p.deviceIntegrity?.deviceRecognitionVerdict ?? [];
  if (labels.includes('MEETS_STRONG_INTEGRITY') || labels.includes('MEETS_DEVICE_INTEGRITY')) return 'allow';
  if (labels.includes('MEETS_BASIC_INTEGRITY')) return 'allow-with-limits';
  return 'step-up';
}
```

## A real-world failure

CVE-2020-0096 (StrandHogg 2.0) was a privilege escalation in Android 8.0 to 9: a confused deputy in `startActivities` let a malicious app place its own activity in another app's task and overlay the trusted app to phish credentials. Google fixed it in the May 2020 security bulletin. Setting `launchMode` to `singleTask` or `singleInstance` on public activities reduced exposure on unpatched devices, but the missing control was the operating system's own check, which is why a server should not treat a client screen as proof of who is acting.

## Test the release build like an attacker

Run these checks against the build you ship, from a normal `adb shell`: `adb root` can start components that other apps can't. To run the link, intent, component, network and storage checks as tests, the Android project on /labs/, Repair an Android app's links, components, network policy and storage, puts a vulnerable and a fixed version of one app through Robolectric tests and Android Lint in a pinned Android SDK container, mapped to MASTG-TEST-0394, MASTG-TEST-0364, MASTG-TEST-0235, MASTG-TEST-0286 and MASTG-TEST-0287.

```bash
APK=app-release.apk; PKG=com.example.bank

# 1. What does the release manifest expose?
aapt2 dump xmltree --file AndroidManifest.xml "$APK" \
  | grep -E 'E: (activity|service|receiver|provider)|exported|debuggable|allowBackup|networkSecurityConfig|dataExtractionRules'

# 2. Signing certificate SHA-256 for assetlinks.json (with Play App Signing, copy it from Play Console)
keytool -printcert -jarfile "$APK" | grep SHA256

# 3. A non-exported activity must refuse other callers: expect "Permission Denial ... not exported"
adb shell am start -n "$PKG/.TransferReviewActivity"

# 4. Feed the link handler hostile URIs; none may reach an invoice screen
for u in 'https://bank.example.com/invoices/INV0000123%0A' \
         'https://bank.example.com/invoices/INV0000123?pay=1' \
         'https://evil.test%5C@bank.example.com/invoices/INV0000123'; do
  adb shell am start -a android.intent.action.VIEW -d "'$u'" -n "$PKG/.InvoiceLinkActivity"
done

# 5. Every App Links domain should end up "verified"
adb shell pm verify-app-links --re-verify "$PKG"; sleep 20; adb shell pm get-app-links "$PKG"

# 6. Nothing secret in the system log while you exercise the app
adb logcat -d --pid="$(adb shell pidof "$PKG")" | grep -iE 'token|bearer|password' && echo 'LEAK'
```

## Guided practice

A payments app is preparing a release that targets API 37. Review the excerpts, decide what an attacker can do with each, and write the fix.

### Supplied material

```text
AndroidManifest.xml
  <application android:allowBackup="true" android:networkSecurityConfig="@xml/nsc">
  <activity android:name=".PayActivity" android:exported="true">
    <intent-filter> VIEW, DEFAULT, BROWSABLE; scheme "bankapp", host "pay" </intent-filter>
  </activity>
  <activity android:name=".AdminActivity" android:exported="false" />

res/xml/nsc.xml
  <base-config cleartextTrafficPermitted="true">
    <trust-anchors> <certificates src="system"/> <certificates src="user"/> </trust-anchors>
  </base-config>

PayActivity.onCreate
  val to = intent.data?.getQueryParameter("to")
  val amount = intent.data?.getQueryParameter("amount")
  if (prefs.getBoolean("biometricOk", false)) api.transfer(to, amount)
  intent.getParcelableExtra("next", Intent::class.java)?.let { startActivity(it) }

NotificationHelper
  PendingIntent.getActivity(ctx, 0, Intent("com.example.bank.OPEN"), PendingIntent.FLAG_MUTABLE)

Session storage
  Refresh token in EncryptedSharedPreferences file "session"; no backup rules.
```

### 1. Close the payment link

Describe what a web page or another app can do with `bankapp://pay?to=X&amount=Y`, then redesign the entry point.

Worked answer: Any app can claim the `bankapp` scheme, and any page can fire the link; once `biometricOk` was set, the transfer runs with no confirmation. Replace it with a verified https App Link that opens a read-only review screen, and authorize the transfer with a server challenge signed by a biometric-bound Keystore key.

### 2. Find the redirection

Explain what the `next` extra lets a caller reach and how to remove the risk.

Worked answer: It is intent redirection: a caller can launch the non-exported `AdminActivity`, or receive URI grants, with the app's identity. Remove the feature, or resolve the target against an allowlist and clear grant flags with `IntentSanitizer`.

### 3. Fix transport

List what is wrong with `nsc.xml` for a release build and write the replacement.

Worked answer: Cleartext is allowed and user-installed CAs are trusted, so a network attacker or a proxy CA can read traffic. Use `cleartextTrafficPermitted="false"`, system trust anchors, Certificate Transparency, and user CAs only in `debug-overrides`.

### 4. Fix the notification

Predict what happens when `NotificationHelper` runs in this release, and rewrite it.

Worked answer: Targeting API 37, creating a mutable `PendingIntent` around an implicit `Intent` throws `IllegalArgumentException`, so the code crashes; on older targets it was hijackable. Use an explicit `Intent` with `FLAG_IMMUTABLE` and `FLAG_ONE_SHOT`.

### 5. Fix storage

Assess the session storage and plan the migration.

Worked answer: `security-crypto` is deprecated, and the file is backed up even though its Keystore key can't be restored. Move the token to AES-GCM under your own Keystore key in `noBackupFilesDir`, exclude it from backup and device transfer, and sign the user in again when decryption fails.

Deliverable: Five findings, each with its MASVS control, the attack, and the corrected code or configuration.

Scope: Paper exercise: the excerpts are complete for the questions asked. You can reproduce the PendingIntent and redirection behavior on an emulator running API 34 or later, and the Android project on /labs/ turns the link, redirection, transport and storage findings into failing tests you repair.

## Executable practice

- [Repair an Android app's links, components, network policy and storage: project ZIP](https://softwaresecurity.com/downloads/projects/android-platform-boundaries-52934d7ed9eeeff3.zip) — Kotlin · Android Gradle Plugin 9.4 · Robolectric 4.17 · Docker

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP MASVS 2.1.0 release and control list](https://github.com/OWASP/masvs/releases/tag/v2.1.0)
- [AndroidX release notes: security-crypto deprecation](https://developer.android.com/jetpack/androidx/releases/security)
- [Android Developers: KeyGenParameterSpec.Builder reference](https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder)
- [Android Developers: Network security configuration](https://developer.android.com/privacy-and-security/security-config)
- [Android Developers: Android 17 behavior changes for apps targeting API 37](https://developer.android.com/about/versions/17/behavior-changes-17)
- [Android Developers: PendingIntent reference (FLAG_MUTABLE, FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT)](https://developer.android.com/reference/android/app/PendingIntent#FLAG_MUTABLE)
- [Android Developers: Insecure WebView native bridges](https://developer.android.com/privacy-and-security/risks/insecure-webview-native-bridges)
- [Android Developers: Verify App Links](https://developer.android.com/training/app-links/verify-applinks)
- [Android Developers: Play Integrity integrity verdicts](https://developer.android.com/google/play/integrity/verdicts)
- [Play Console Help: Target API level requirements for Google Play apps](https://support.google.com/googleplay/android-developer/answer/11926878)
- [NVD: CVE-2020-0096, Android StrandHogg 2.0 task hijacking](https://nvd.nist.gov/vuln/detail/cve-2020-0096)
- [Android Security Bulletin, May 2020 (CVE-2020-0096)](https://source.android.com/docs/security/bulletin/2020-05-01)
- [Android Developers: Cryptography (security-crypto deprecation)](https://developer.android.com/privacy-and-security/cryptography)
- [OWASP MASTG v2.0.0 release](https://github.com/OWASP/mastg/releases/tag/v2.0.0). v2.0.0.