know.2nth.ai Technology tech android-hce hce
tech/android-hce · HCE · Skill Leaf

Tap-to-pay
without hardware.

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.

Payment NFC HostApduService EMV Android 4.4+

HostApduService, AID routing, and how a tap works.

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.

Payment-category AIDs require scheme licensing.

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.

SELECT → GPO → READ RECORD → GENERATE AC.

A contactless EMV transaction is a strict command-response sequence. The reader drives. Your service responds. No room for improvisation.

SELECT (PPSE) — Reader asks for Payment Proximity SE
App returns list of supported AIDs

SELECT (AID) — Reader picks a specific application AID
App returns FCI (File Control Information)

GET PROCESSING OPTIONS — Reader sends PDOL data
App returns AIP + AFL (capabilities + file locators)

READ RECORD — Reader reads card data files
App returns DPAN, expiry, cardholder name

GENERATE AC — Reader sends transaction data + unpredictable number
App generates cryptogram using LUK, returns AC

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
9000SuccessCommand processed. Response data precedes the SW.
6A82File not foundAID not recognised or record not available.
6A86Wrong P1/P2Parameters don't match what the app supports.
6985Conditions not satisfiedPrerequisite command not issued (e.g., GPO before SELECT).
6F00Unknown errorYour catch-all. Return this from exception handlers.

Eight hard rules. Break any of them and you ship a liability.

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.

1. Never log raw APDU bytes in release

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.

2. Never write PAN/CVV/track data to disk

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.

3. Keystore keys with the right flags

Every payment-related Keystore key must have: userAuthenticationRequired, invalidatedByBiometricEnrollment, unlockedDeviceRequired, and isStrongBoxBacked (with TEE fallback). Miss any one and you've weakened the chain.

4. No network calls on the tap path

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

5. Timing-safe comparisons for MACs and cryptograms

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.

6. Wrap processCommandApdu in try/catch

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.

7. Play Integrity for provisioning and replenishment

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.

8. Don't trust device clock alone

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()
    }
}

DPAN, LUKs, and the provisioning lifecycle.

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

Pre-provision for resilience, not just convenience.

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.

ZAR defaults, POPIA, load-shedding, and the device matrix.

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:

Last-4 only in UI

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.

120-day retention limit

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.

72-hour breach notification

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.

Cross-border transfer basis

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:

Offline-first, always.

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.

Things that only bite in production.

Six failure modes discovered by teams that shipped payment apps and then spent weeks debugging silent failures at POS terminals.

Don't use payment-category AIDs you aren't licensed for

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.

Don't grab setPreferredService outside a foreground Activity

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.

Don't put long crypto ops in processCommandApdu synchronously

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.

Don't trust the device clock for transaction timestamps

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.

Don't store LUKs as individual Keystore entries

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.

NFC field loss is normal, not exceptional

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.

Use when / skip when.

HCE is the right tool for a specific job. It's not the right tool for every NFC use case.

Use HCE when

  • You're building a mobile wallet or tap-to-pay app and have (or will get) payment scheme licensing
  • You need contactless card emulation without carrier/OEM Secure Element dependencies
  • Your target market includes devices without Secure Elements (most mid-range Android phones)
  • You're building loyalty, transit, or access-control tap experiences (category="other" AIDs are free to use)
  • You want full software control over the transaction flow for custom business logic
  • You need to support offline transactions with pre-provisioned cryptographic material

How this node compounds in the tree.

HCE is the tap surface. Everything behind it — provisioning, reconciliation, fraud monitoring — lives in other nodes. The compounding happens at the connections.

tech/cloudflare/workers
Cloudflare Workers
The provisioning backend. Workers handle TSP communication, LUK replenishment requests, and Play Integrity validation. Edge compute means the provisioning server is 50ms from any SA user, not 200ms from eu-west-1.
biz/erp
ERP Integration
Transaction reconciliation. Every tap generates a settlement record that flows into the ERP's accounts receivable. The ERP skill handles the Frappe/SAP/Sage side of matching transactions to payments.
security/identity
Identity & Auth
Biometric enrolment, device attestation, and fraud scoring all live here. HCE consumes identity verification during provisioning and continuous authentication during high-value transactions.
data/observability
Observability
Tap success rates, cryptogram generation latency, LUK consumption rates, field-loss frequency. The observability node provides the monitoring stack that turns silent failures into alerts.
tech/android-hce/emv
EMV Specification
The protocol spec that defines every tag, every APDU structure, every cryptogram format. HCE implements EMV — reading the spec is non-optional for anyone modifying the transaction flow.
iot/pos-terminals
POS Terminal Integration
The other side of the NFC field. Understanding terminal behaviour, certification requirements, and contactless kernel implementations helps debug the 10% of taps that fail on specific terminal models.

Go deeper.

The Android HCE guide is the starting point. The EMV specs are the ending point. Everything in between is implementation detail.