Android Keystore is the hardware-backed vault that holds your payment cryptographic material. StrongBox puts keys inside a dedicated secure processor. TEE keeps them in a trusted execution environment. Either way, private key bytes never enter your app's address space — and if any flag is wrong, you ship a wallet that a rooted device can plunder in minutes.
The Android Keystore system lets you generate and use cryptographic keys that are bound to hardware security modules on the device. For payment apps, this is non-negotiable: the wrap-key that protects your LUKs (Limited Use Keys) must live inside hardware that resists extraction even on a rooted device.
StrongBox is the gold standard — a discrete secure processor (like the Titan M chip on Pixel, or Samsung's eSE) that runs its own firmware, has its own CPU, and is physically tamper-resistant. TEE (Trusted Execution Environment) is the fallback — it runs in an isolated region of the main processor. Both prevent key material from being read by the OS, but StrongBox survives kernel compromises that TEE may not.
The key hierarchy for a payment wallet: a single AES-256 wrap-key lives in Keystore (StrongBox-backed where available). This key encrypts and decrypts LUK files stored on the filesystem. LUKs themselves are not individual Keystore entries — that approach hits Keystore's practical entry limit (~256 on many devices). Instead, LUKs are encrypted blobs, wrapped by the one Keystore key.
| Component | Role |
|---|---|
KeyGenerator |
Creates symmetric keys (AES) inside the Keystore. Used for the wrap-key that encrypts LUK files. |
KeyPairGenerator |
Creates asymmetric keys (EC) inside the Keystore. Used for attestation-bound keypairs during provisioning. |
KeyGenParameterSpec |
The builder that sets every security flag. One wrong flag and the key is extractable or usable without authentication. |
BiometricPrompt |
Gates cryptographic operations behind fingerprint or face recognition. Required for CDCVM (Consumer Device Cardholder Verification Method). |
| Play Integrity API | Attests device integrity before provisioning or LUK replenishment. Blocks rooted devices and modified frameworks. |
| Key Attestation | Proves a key was generated inside hardware. Fallback for non-GMS devices (Huawei) where Play Integrity is unavailable. |
Three operations define the security surface: generating the wrap-key with the correct flags, extracting the attestation chain for server verification, and requiring biometric auth before any cryptographic operation.
The four required flags. Every payment-related Keystore key must set all four. Missing any one creates a vulnerability that won't show up in testing but will show up in a security audit — or worse, in production.
| Flag | What it does | What happens without it |
|---|---|---|
setUserAuthenticationRequired(true) |
Key operations require biometric or lock-screen auth | Any app process can use the key without user presence |
setInvalidatedByBiometricEnrollment(true) |
Key is destroyed if new biometrics are enrolled | An attacker who adds their fingerprint can use existing keys |
setUnlockedDeviceRequired(true) |
Key cannot be used while device is locked | Background processes or lock-screen exploits can trigger key ops |
setIsStrongBoxBacked(true) |
Key material lives in StrongBox secure processor | Key lives in TEE only — still hardware-backed, but weaker isolation |
AES wrap-key generation with StrongBox + TEE fallback. Try StrongBox first. If the device lacks a StrongBox (most mid-range phones), fall back to TEE. Never fall back to software-only.
fun createWrapKey(alias: String): SecretKey { val keyGen = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore" ) fun buildSpec(strongBox: Boolean) = KeyGenParameterSpec.Builder( alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) .setUserAuthenticationRequired(true) .setUserAuthenticationParameters( 0, // every use requires auth KeyProperties.AUTH_BIOMETRIC_STRONG ) .setInvalidatedByBiometricEnrollment(true) .setUnlockedDeviceRequired(true) .setIsStrongBoxBacked(strongBox) .build() return try { keyGen.init(buildSpec(strongBox = true)) keyGen.generateKey() } catch (e: StrongBoxUnavailableException) { // TEE fallback — still hardware-backed keyGen.init(buildSpec(strongBox = false)) keyGen.generateKey() } }
EC keypair for attestation during provisioning. When the server needs to verify that your key was generated inside real hardware (not an emulator or a tampered device), you generate an attestation-bound EC keypair and send the certificate chain upstream.
fun createAttestationKeypair( alias: String, challenge: ByteArray // server-supplied nonce ): KeyPair { val kpg = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore" ) val spec = KeyGenParameterSpec.Builder( alias, KeyProperties.PURPOSE_SIGN ) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) .setDigests(KeyProperties.DIGEST_SHA256) .setAttestationChallenge(challenge) .setUserAuthenticationRequired(true) .setInvalidatedByBiometricEnrollment(true) .setUnlockedDeviceRequired(true) .setIsStrongBoxBacked(true) // wrap in try/catch for TEE fallback .build() kpg.initialize(spec) return kpg.generateKeyPair() } fun extractAttestationChain(alias: String): List<Certificate> { val ks = KeyStore.getInstance("AndroidKeyStore") ks.load(null) return ks.getCertificateChain(alias).toList() // Send this chain to your server. // Server verifies root is Google's attestation root CA // and the extension data matches expected security level. }
BiometricPrompt for CDCVM. Consumer Device Cardholder Verification Method means the user authenticates on their phone instead of entering a PIN on the terminal. This is how contactless payments above the floor limit work. The biometric result unlocks the Keystore key for one cryptographic operation.
fun authenticateAndDecrypt( activity: FragmentActivity, alias: String, encryptedLuk: ByteArray, iv: ByteArray, onDecrypted: (ByteArray) -> Unit, onError: (String) -> Unit ) { val ks = KeyStore.getInstance("AndroidKeyStore") ks.load(null) val key = ks.getKey(alias, null) as SecretKey val cipher = Cipher.getInstance("AES/GCM/NoPadding") cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded( result: BiometricPrompt.AuthenticationResult ) { val authedCipher = result.cryptoObject?.cipher ?: return onError("No cipher in result") val plaintext = authedCipher.doFinal(encryptedLuk) onDecrypted(plaintext) } override fun onAuthenticationError( code: Int, msg: CharSequence ) { onError(msg.toString()) } } ) val promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle("Confirm payment") .setSubtitle("Authenticate to tap") .setNegativeButtonText("Cancel") .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) .build() prompt.authenticate( promptInfo, BiometricPrompt.CryptoObject(cipher) ) }
Play Integrity is a network call — it cannot run on the tap path. Use it to gate token provisioning, LUK replenishment, and high-value operations. The server checks the integrity verdict before issuing new LUKs. A rooted device fails the check and cannot replenish — but existing LUKs still work for taps (they're already on-device and authenticated by biometric).
Keystore errors don't crash your app. They surface as InvalidKeyException at the worst possible moment — mid-tap, during provisioning, or after a user enrols a new fingerprint.
When setInvalidatedByBiometricEnrollment(true) is set and the user adds a new fingerprint, the key is permanently destroyed. Your app must detect this (KeyPermanentlyInvalidatedException), wipe the local token data, and re-provision from scratch. Users don't understand why adding a fingerprint killed their wallet.
StrongBox on many devices only supports AES-256 and EC P-256. If you need RSA or other curves, StrongBox will throw at key generation. Always have the TEE fallback path tested and ready — not just coded.
The practical limit varies by device (often ~256 entries). If you store each LUK as a Keystore entry, you hit this ceiling fast. Use a single wrap-key in Keystore and store LUKs as encrypted files. The Keystore is a vault for a small number of high-value keys, not a database.
No Google Play Services means no Play Integrity API. You need Android Key Attestation as your fallback: generate the keypair with setAttestationChallenge(), extract the certificate chain, and verify the root against Google's published attestation root certificates on your server. Huawei's HMS SafetyDetect is a second option but adds an HMS SDK dependency.
If the biometric auth timeout expires between the user's authentication and the actual tap, the Keystore key becomes unusable. Set setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG) to require auth per-use, and trigger BiometricPrompt before the user taps, not during processCommandApdu where you can't show UI.
Not every key needs StrongBox. But every payment key needs at least TEE. Here's how to decide.
| Key type | Minimum backing | Rationale |
|---|---|---|
| LUK wrap-key (AES-256) | StrongBox preferred, TEE minimum | Protects all LUKs on device. Compromise = full wallet breach. |
| Attestation keypair (EC P-256) | StrongBox preferred, TEE minimum | Proves device integrity to provisioning server. Must be non-exportable. |
| Transport encryption key | TEE | Protects server communication. StrongBox latency unnecessary here. |
| Analytics encryption key | Software Keystore acceptable | Non-payment data. Hardware backing is overhead without proportional security benefit. |
Don't assume StrongBox exists. Query PackageManager.FEATURE_STRONGBOX_KEYSTORE at app startup and log the result. Your server should know whether each device's wrap-key is StrongBox-backed or TEE-backed — it changes the risk profile for that installation.