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 PendingIntents 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.

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.
Applies to
  • Android 7.0 (API 24) to 17 (API 37); tested on an API 37 emulator
  • Google Play target API level API 36 for new apps and updates since 31 August 2026
  • androidx.biometric 1.1.0
  • androidx.webkit 1.17.1
  • Play Integrity API library 1.6.0
  • androidx.security:security-crypto 1.1.0, deprecated with no further releases

Android apps written in Kotlin for current API levels. Keystore and Play Integrity behavior varies by device, OS version and hardware backing.

Part of
Workflow: Review a design

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 37 minutes: 11 to read and 26 for the exercise

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.

Before you start

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

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

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.

Map each MASVS 2.1 control to an Android mechanism
ControlRequirementAndroid mechanism
MASVS-STORAGE-1Store sensitive data securelyApp-private storage, encrypted with an Android Keystore key
MASVS-STORAGE-2Prevent leaksdataExtractionRules, noBackupFilesDir, release log stripping, FLAG_SECURE
MASVS-CRYPTO-1, CRYPTO-2Strong cryptography and key managementAES-256-GCM and P-256 ECDSA with non-exportable Keystore keys
MASVS-AUTH-2, AUTH-3Local authentication and step-upBiometricPrompt with a CryptoObject over an auth-bound key
MASVS-NETWORK-1Secure all trafficNetwork Security Configuration: no cleartext, system CAs, Certificate Transparency
MASVS-NETWORK-2Identity pinning (L2)NSC pin-set with a backup pin and an expiry
MASVS-PLATFORM-1Secure IPCExplicit android:exported, signature permissions, immutable PendingIntents
MASVS-PLATFORM-2Secure WebViewsJavaScript and file access off, WebViewAssetLoader, origin-bound message listener
MASVS-CODE-1, CODE-4Current platform, validated inputtargetSdk 36 or higher, strict parsing of links and extras
MASVS-RESILIENCE-1, -2Platform and app integrityPlay 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.

TokenCipher.ktKotlin
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.

StepUp.ktKotlin
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).
Configurationres/xml/data_extraction_rules.xmlXML
<?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.

Configurationres/xml/network_security_config.xmlXML
<?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.

Configurationres/xml/network_security_config.xmlXML
<?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().

ConfigurationAndroidManifest.xmlXML
<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.

Reminders.ktKotlin
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.

Help.ktKotlin
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")
}

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.

server/integrity.mjsJavaScript
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.

Testverify-android.shShell
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 scenario material
Plain 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. 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.

    Expected result and reasoning

    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. 2. Find the redirection

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

    Expected result and reasoning

    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. 3. Fix transport

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

    Expected result and reasoning

    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. 4. Fix the notification

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

    Expected result and reasoning

    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. 5. Fix storage

    Assess the session storage and plan the migration.

    Expected result and reasoning

    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.

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

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.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Run a local project

Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.

Kotlin · Android Gradle Plugin 9.4 · Robolectric 4.17 · Docker · 45–75 min

Repair an Android app's links, components, network policy and storage

Repair a Kotlin banking app until its Robolectric tests and Android Lint pass in a pinned SDK container: links and intent redirection, exported screens, network security configuration and Keystore-encrypted storage.

Needs: Docker, running the image as linux/amd64 (Rosetta or emulation on Apple silicon), and about 1.2 GB of downloads on the first run.

In the extracted folder, run:

Shell
docker run --rm --init --platform linux/amd64 \
  -v "$PWD":/project -w /project \
  -v android-boundaries-gradle:/root/.gradle -v android-boundaries-m2:/root/.m2 \
  -e EXERCISE_MODULE \
  ghcr.io/cirruslabs/android-sdk:36@sha256:f9b3ea9ed2b5fc9522adae82c7b4622ab7aa54207ef532c8e615a347dca08f31 \
  sh run-tests.sh

20 regression tests · The starter intentionally fails some cases.

No dev container: the README's docker command runs the pinned SDK image.

JVM tests on Robolectric's Android 16 framework plus Lint, not an emulator: the Keystore and network configuration are checked as key specification and compiled resource.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP MASVS 2.1.0 release and control list
  2. AndroidX release notes: security-crypto deprecation
  3. Android Developers: KeyGenParameterSpec.Builder reference
  4. Android Developers: Network security configuration
  5. Android Developers: Android 17 behavior changes for apps targeting API 37
  6. Android Developers: PendingIntent reference (FLAG_MUTABLE, FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT)
  7. Android Developers: Insecure WebView native bridges
  8. Android Developers: Verify App Links
  9. Android Developers: Play Integrity integrity verdicts
  10. Play Console Help: Target API level requirements for Google Play apps
  11. NVD: CVE-2020-0096, Android StrandHogg 2.0 task hijacking
  12. Android Security Bulletin, May 2020 (CVE-2020-0096)
  13. Android Developers: Cryptography (security-crypto deprecation)
  14. OWASP MASTG v2.0.0 release. v2.0.0.

Reference information

SDLC phase
Development
OWASP MASVS v2 controls
MASVS-STORAGE-1 · MASVS-STORAGE-2 · MASVS-CRYPTO-1 · MASVS-CRYPTO-2 · MASVS-AUTH-2 · MASVS-AUTH-3 · MASVS-NETWORK-1 · MASVS-NETWORK-2 · MASVS-PLATFORM-1 · MASVS-PLATFORM-2 · MASVS-PLATFORM-3 · MASVS-CODE-1 · MASVS-CODE-4 · MASVS-RESILIENCE-1 · MASVS-RESILIENCE-2

Download this guide as Markdown