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
BiometricPromptcallback 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
PendingIntentaround an implicitIntentthrows. Use explicit, immutable intents. - Decode Play Integrity tokens on your server, bind them to the request with
requestHash, and never cache a verdict.
| 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 |
- Dump the release manifest with
aapt2 dump xmltreeand confirm every exported component is meant to be public,android:debuggableis absent, and the network and backup rules are referenced. - From a normal
adb shell(notadb root), start each non-exported activity witham start -nand expectPermission Denial ... not exported; send hostile URIs to every exported link handler. - After installing a store build, run
adb shell pm get-app-linkson the package and confirm every domain reportsverified. - 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. - 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.
- 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.
- 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
Intentfrom 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
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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.
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 PendingIntents |
| 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 |
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.
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)
}
}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.
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.
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))
}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
-assumenosideeffectsrules, and never log tokens at any level. - Set
FLAG_SECUREon 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 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>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 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>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 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 -->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().
<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>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.
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)
}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.
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")
}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.
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.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.
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';
}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.
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.
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
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.Expected result and reasoning
Any app can claim the
bankappscheme, and any page can fire the link; oncebiometricOkwas 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
nextextra 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 withIntentSanitizer.3. Fix transport
List what is wrong with
nsc.xmlfor 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 indebug-overrides.4. Fix the notification
Predict what happens when
NotificationHelperruns in this release, and rewrite it.Expected result and reasoning
Targeting API 37, creating a mutable
PendingIntentaround an implicitIntentthrowsIllegalArgumentException, so the code crashes; on older targets it was hijackable. Use an explicitIntentwithFLAG_IMMUTABLEandFLAG_ONE_SHOT.5. Fix storage
Assess the session storage and plan the migration.
Expected result and reasoning
security-cryptois 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 innoBackupFilesDir, exclude it from backup and device transfer, and sign the user in again when decryption fails.
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.
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:
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.sh20 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.
- OWASP MASVS 2.1.0 release and control list
- AndroidX release notes: security-crypto deprecation
- Android Developers: KeyGenParameterSpec.Builder reference
- Android Developers: Network security configuration
- Android Developers: Android 17 behavior changes for apps targeting API 37
- Android Developers: PendingIntent reference (FLAG_MUTABLE, FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT)
- Android Developers: Insecure WebView native bridges
- Android Developers: Verify App Links
- Android Developers: Play Integrity integrity verdicts
- Play Console Help: Target API level requirements for Google Play apps
- NVD: CVE-2020-0096, Android StrandHogg 2.0 task hijacking
- Android Security Bulletin, May 2020 (CVE-2020-0096)
- Android Developers: Cryptography (security-crypto deprecation)
- 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