Android Host Card Emulation lets your app respond to NFC taps as if it were a physical contactless card. No Secure Element. No carrier deals. Just a HostApduService, AID-based routing, and a cryptographic model that fails catastrophically and silently if you get it wrong.
Before HCE, contactless payments on Android required a Secure Element — a tamper-resistant chip controlled by the carrier or OEM. HCE removed that dependency. Since Android 4.4, any app can register as a card emulation service and receive APDUs (Application Protocol Data Units) directly from an NFC reader.
The architecture is straightforward: you extend HostApduService, declare which AIDs (Application Identifiers) you handle in an XML resource, and the Android NFC stack routes incoming taps to your service based on AID matching. When a POS terminal sends a SELECT command with your AID, Android wakes your service and hands you raw bytes.
You process the APDU command, build a response (data + status word), and return it. The reader sends the next command. This ping-pong continues until the transaction completes or the phone leaves the field. The entire exchange typically takes 300–500ms.
| Component | Role |
|---|---|
HostApduService |
Your service. Receives APDU commands, returns APDU responses. Runs in your app process. |
apduservice.xml |
Declares AID groups. Tells Android which APDUs to route to your service. |
| AID routing | Android matches the AID in a SELECT command to registered services. Payment-category AIDs have special rules. |
| DPAN | Device PAN — the tokenised card number. The real PAN never touches the device. |
| LUK | Limited Use Key — a single-use cryptographic key for generating transaction cryptograms. |
You can use category="other" AIDs freely for loyalty cards, transit, access control. But declaring category="payment" AIDs (Visa, Mastercard, etc.) means you need a licence from the payment scheme. Don't ship with payment AIDs you haven't been authorised to use — the schemes audit, and they will find you.
A contactless EMV transaction is a strict command-response sequence. The reader drives. Your service responds. No room for improvisation.
The minimal HostApduService. Every command arrives as a byte array. You parse the class, instruction, P1, P2, and data. You return data + a two-byte status word. 0x9000 means success. Everything else is an error the reader has to interpret.
class PaymentHceService : HostApduService() { companion object { private val SELECT_APDU_HEADER = byteArrayOf( 0x00.toByte(), 0xA4.toByte(), 0x04.toByte(), 0x00.toByte() ) private val SW_OK = byteArrayOf(0x90.toByte(), 0x00.toByte()) private val SW_UNKNOWN = byteArrayOf(0x6F.toByte(), 0x00.toByte()) private val SW_NOT_FOUND = byteArrayOf(0x6A.toByte(), 0x82.toByte()) } override fun processCommandApdu( commandApdu: ByteArray, extras: Bundle? ): ByteArray { return try { when { isSelect(commandApdu) -> handleSelect(commandApdu) isGpo(commandApdu) -> handleGpo(commandApdu) isReadRecord(commandApdu) -> handleReadRecord(commandApdu) isGenerateAc(commandApdu) -> handleGenerateAc(commandApdu) else -> SW_NOT_FOUND } } catch (e: Exception) { // Never let an exception crash the tap SW_UNKNOWN } } override fun onDeactivated(reason: Int) { // Clean up session state. reason is either // DEACTIVATION_LINK_LOSS or DEACTIVATION_DESELECTED } private fun isSelect(apdu: ByteArray): Boolean = apdu.size >= 4 && apdu[0] == 0x00.toByte() && apdu[1] == 0xA4.toByte() // ... isGpo, isReadRecord, isGenerateAc follow the same pattern // checking CLA + INS bytes against EMV spec values }
The AID group declaration. This XML file lives in res/xml/ and is referenced from the service declaration in your manifest. The category attribute is critical.
<!-- res/xml/apduservice.xml --> <host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android" android:description="@string/hce_service_description" android:requireDeviceUnlock="true"> <aid-group android:category="payment" android:description="@string/aid_group_description"> <!-- Visa AID — requires scheme licence --> <aid-filter android:name="A0000000031010" /> <!-- Mastercard AID — requires scheme licence --> <aid-filter android:name="A0000000041010" /> </aid-group> </host-apdu-service>
Status words. Every APDU response ends with two bytes. These are the ones that matter in practice.
| SW | Hex | Meaning |
|---|---|---|
9000 | Success | Command processed. Response data precedes the SW. |
6A82 | File not found | AID not recognised or record not available. |
6A86 | Wrong P1/P2 | Parameters don't match what the app supports. |
6985 | Conditions not satisfied | Prerequisite command not issued (e.g., GPO before SELECT). |
6F00 | Unknown error | Your catch-all. Return this from exception handlers. |
Payment apps don't get second chances. A PAN leak is a breach. A timing side-channel is a vulnerability. These rules aren't guidelines — they're the minimum for shipping something that handles other people's money.
APDU payloads contain the PAN, expiry, and cryptograms. A Log.d() left in a release build is a data breach waiting for someone to plug in ADB. Strip all APDU logging behind BuildConfig.DEBUG checks or use ProGuard to remove log calls entirely.
The DPAN (tokenised number) can be persisted. The real PAN must never touch storage, SharedPreferences, or a database. If you're storing "the card number" and it's not a token, you're building a breach report, not an app.
Every payment-related Keystore key must have: userAuthenticationRequired, invalidatedByBiometricEnrollment, unlockedDeviceRequired, and isStrongBoxBacked (with TEE fallback). Miss any one and you've weakened the chain.
processCommandApdu must be fully offline. You have ~300ms total for the entire tap. A network call that takes 200ms on Wi-Fi takes 2000ms on a congested cell tower — and the reader has already timed out and shown "card declined".
Use MessageDigest.isEqual() for comparing authentication codes. Standard Arrays.equals() or contentEquals() short-circuit on the first differing byte, leaking information about the expected value through timing.
An unhandled exception in processCommandApdu crashes the service mid-tap. The reader sees a link loss. The user sees a declined transaction with no error message. Return SW 6F00 (unknown error) from the catch block — at least the reader gets a clean failure.
Gate token provisioning, LUK replenishment, and high-value operations behind Play Integrity attestation. A rooted device or one with a modified framework should not be able to provision new tokens without additional verification.
Transaction timestamps matter for dispute resolution and cryptogram validation. Include the reader's Unpredictable Number (UN) in your cryptogram input. The device clock can be wrong; the UN from the reader is the reader's contribution to freshness.
Keystore key generation with the required flags. This is the pattern for creating a key that protects LUK encryption or transaction signing material.
private fun generatePaymentKey(alias: String): SecretKey { val keyGen = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore" ) val specBuilder = KeyGenParameterSpec.Builder( alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) .setUserAuthenticationRequired(true) .setInvalidatedByBiometricEnrollment(true) .setUnlockedDeviceRequired(true) // StrongBox with TEE fallback try { specBuilder.setIsStrongBoxBacked(true) keyGen.init(specBuilder.build()) return keyGen.generateKey() } catch (e: StrongBoxUnavailableException) { // Fall back to TEE — still hardware-backed, just not StrongBox val teeSpec = KeyGenParameterSpec.Builder( alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(256) .setUserAuthenticationRequired(true) .setInvalidatedByBiometricEnrollment(true) .setUnlockedDeviceRequired(true) .build() keyGen.init(teeSpec) return keyGen.generateKey() } }
The real card number never touches the device. Instead, a Device PAN (DPAN) is provisioned through a Token Service Provider, along with Limited Use Keys for generating per-transaction cryptograms.
DPAN vs PAN. The DPAN is a token that maps back to the real PAN at the network level. The issuer and the TSP (Token Service Provider — Visa VTS, Mastercard MDES) maintain this mapping. Your app only ever sees the DPAN. When the acquirer sends the cryptogram upstream, the network de-tokenises and routes to the issuer.
LUK lifecycle. Limited Use Keys are pre-provisioned, single-use cryptographic keys. Each LUK generates exactly one transaction cryptogram. Once used, it's discarded. The app pre-provisions a batch and replenishes from the server before running out.
| Phase | What happens | Keys involved |
|---|---|---|
| Provisioning | Biometric enrolment → server identity check → attestation-bound EC keypair → submit to TSP → receive DPAN + initial LUK batch | Device attestation key, transport key |
| Storage | LUKs stored as encrypted files wrapped by an AES Keystore key. Not as Keystore keys themselves (Keystore has entry limits). | Keystore wrapping key |
| Usage | On tap: pop next LUK, generate cryptogram with transaction data + reader UN, discard LUK | Single LUK (consumed) |
| Replenishment | When LUK count drops below threshold, request new batch from server. Gate behind Play Integrity. | Transport key, new LUK batch |
| Deactivation | Remote wipe of DPAN + all LUKs. Triggered by user, issuer, or fraud system. | All keys deleted |
Keep 5–10 LUKs on device at all times — roughly 24 hours of typical usage. In South Africa, load-shedding can take your provisioning server offline for hours. If the user has zero LUKs when the power goes out, their wallet is dead until the server comes back. Pre-provisioning is your offline buffer.
Building a payment app for South Africa has specific constraints that don't show up in the Android docs or EMV specs. These are the ones that bite.
| Parameter | Value | Where it goes |
|---|---|---|
| Currency code | 0710 (ZAR) |
Tag 5F2A — Transaction Currency Code |
| Country code | 0710 (ZA) |
Tag 9F1A — Terminal Country Code |
| Timezone | Africa/Johannesburg (SAST, UTC+2) |
Hard-coded. SA doesn't observe daylight saving time. Don't use device locale. |
POPIA compliance. The Protection of Personal Information Act governs how you handle cardholder data in South Africa. The payment-specific rules:
Display at most the last four digits of the DPAN in any user-facing screen, notification, or receipt. Full DPAN in logs or analytics is a POPIA violation.
Transaction data older than 120 days must be purged or anonymised unless you have a documented legal basis for longer retention. "We might need it" is not a legal basis.
If cardholder data is compromised, you have 72 hours to notify the Information Regulator. Build your incident response plan before you launch, not after the breach.
If your provisioning server or TSP is outside South Africa, you need a documented legal basis for transferring personal information across borders. Binding corporate rules or consent — pick one and document it.
Load-shedding resilience. South Africa's rolling blackouts take infrastructure offline unpredictably. For a payment app, this means your provisioning and replenishment servers may be unreachable for 2–4 hour blocks. The app must degrade gracefully:
Pre-provision 5–10 LUKs. Cache the DPAN and card metadata locally (encrypted). The tap path is already offline-only. Replenishment should retry with exponential backoff and succeed silently when connectivity returns. Never show the user an error about LUK replenishment unless they're actually out of keys.
Device testing matrix. The Android NFC stack behaves differently across OEMs. These are the devices that catch bugs the Pixel won't.
| Device class | Why it matters |
|---|---|
| Samsung A-series | Highest volume Android phones in SA. NFC antenna placement varies by model — tap positioning that works on a Pixel may not work on an A15. |
| Huawei (without GMS) | No Google Play Services means no Play Integrity. You need a fallback attestation path. Huawei's HMS has its own safety detect API. |
| Transsion (Tecno, Itel, Infinix) | Massive SA market share in entry-level segment. NFC support is inconsistent — some models have the hardware but ship with NFC disabled in firmware. Test availability at runtime, not just in the manifest. |
| Xiaomi / Redmi | MIUI's aggressive battery optimization can kill HCE services in the background. Users need to manually exempt your app from battery restrictions. |
Six failure modes discovered by teams that shipped payment apps and then spent weeks debugging silent failures at POS terminals.
Declaring category="payment" with a Visa or Mastercard AID in your manifest without a scheme licence will work in development. It will also trigger a compliance audit in production. Use category="other" for prototyping and testing with your own AIDs.
CardEmulation.setPreferredService() only works when called from a foreground Activity. Calling it from a Service or background context silently does nothing. Your app won't be the default tap handler and you'll get no error message telling you why.
If your cryptogram generation takes more than ~50ms, the reader may time out before your response arrives. Use sendResponseApdu() for deferred responses — return null from processCommandApdu, compute the response asynchronously, then call sendResponseApdu() when ready.
Users set their clocks wrong. Timezone databases get stale. Include the reader's Unpredictable Number (UN) in your cryptogram input — it's the reader's contribution to transaction freshness and doesn't depend on the device clock being correct.
The Android Keystore has practical limits on the number of entries (varies by device, but often ~256). If you store each LUK as a separate Keystore key, you'll hit the limit. Store LUKs as encrypted files wrapped by a single Keystore key instead.
Users move their phones. The field drops mid-transaction. Your service gets onDeactivated(DEACTIVATION_LINK_LOSS). Clean up session state, discard any partially-used LUK, and be ready for the user to tap again immediately. Don't show an error for a single field loss — only after repeated failures.
HCE is the right tool for a specific job. It's not the right tool for every NFC use case.
HCE is the tap surface. Everything behind it — provisioning, reconciliation, fraud monitoring — lives in other nodes. The compounding happens at the connections.
The Android HCE guide is the starting point. The EMV specs are the ending point. Everything in between is implementation detail.