TEJVON
Back to Engineering Impact
BLE Security 13 min readJune 2026

Seven-Stage Secure BLE Pairing: Implementing Anti-MITM Protection on Android

LE Secure Connections is the right approach. Most implementations skip four of the seven stages. Here's the complete implementation.

Summary

BLE pairing is commonly misunderstood and more commonly misimplemented. 'Just Works' pairing provides zero MITM protection. For connected products handling sensitive data — medical devices, automotive systems, financial hardware — LE Secure Connections with proper authentication is non-negotiable. This is the seven-stage process, implemented.

T

Surendra — TEJVON

Senior Android & BLE Engineer

On a project building a connected automotive diagnostic device — a system that could receive firmware commands and transmit vehicle data — security was not optional. We implemented the full LE Secure Connections pairing flow with Numeric Comparison authentication. This article documents that implementation, the seven stages, and the specific Android quirks that aren't in the Bluetooth specification.

The Pairing Model: Why "Just Works" Is Not Enough

Bluetooth LE has three pairing association models: Just Works (no user interaction, zero MITM protection), Passkey Entry (6-digit PIN, protects against passive eavesdropping but vulnerable to observational attacks), and Numeric Comparison (LE Secure Connections, the only model providing genuine MITM protection). For any product where an attacker could intercept commands or inject data, Just Works is not a design choice — it's a vulnerability.

Warning

Just Works pairing sends all data unencrypted relative to MITM. An attacker with a BLE sniffer and a device spoofing your peripheral's address can intercept and inject commands. For medical devices, automotive systems, or anything security-relevant, this is unacceptable.

The Seven Stages of LE Secure Connections Pairing

  1. 1Connection Establishment — ACL connection formed, physical layer secured
  2. 2Pairing Feature Exchange — I/O capabilities, OOB data flag, authentication requirements declared
  3. 3Public Key Exchange — ECDH P-256 key pair generated, public keys exchanged
  4. 4Authentication Stage 1 — Nonce exchange for association model selection
  5. 5Authentication Stage 2 — DHKey check computation (prevents MITM)
  6. 6Long Term Key (LTK) Generation — derived from DHKey, stored for re-bonding
  7. 7Transport Specific Key Distribution — IRK, CSRK, LTK distributed per transport

Android Implementation: Advertising with Security Requirements

Advertising with LE Secure Connections requirementkotlin
// Declare your GATT service with encrypted read/write permissions
val characteristic = BluetoothGattCharacteristic(
    SECURE_CHAR_UUID,
    BluetoothGattCharacteristic.PROPERTY_READ or
    BluetoothGattCharacteristic.PROPERTY_WRITE,
    // PERMISSION_READ_ENCRYPTED_MITM requires bonding with MITM protection
    BluetoothGattCharacteristic.PERMISSION_READ_ENCRYPTED_MITM or
    BluetoothGattCharacteristic.PERMISSION_WRITE_ENCRYPTED_MITM
)

// When a central attempts to read/write without pairing,
// Android returns GATT_INSUFFICIENT_AUTHENTICATION (0x05)
// This triggers the pairing flow automatically on the central side

Handling the Pairing Request on Android Central

BroadcastReceiver for pairing events — required for Numeric Comparisonkotlin
class BlePairingReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val device = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)
            ?: return

        when (intent.action) {
            BluetoothDevice.ACTION_PAIRING_REQUEST -> {
                val variant = intent.getIntExtra(
                    BluetoothDevice.EXTRA_PAIRING_VARIANT,
                    BluetoothDevice.ERROR
                )
                when (variant) {
                    BluetoothDevice.PAIRING_VARIANT_PASSKEY_CONFIRMATION -> {
                        // Numeric Comparison — show the 6-digit code to user
                        val passkey = intent.getIntExtra(BluetoothDevice.EXTRA_PAIRING_KEY, -1)
                        showNumericConfirmationDialog(device, passkey)
                        abortBroadcast() // Prevent system dialog from also showing
                    }
                    BluetoothDevice.PAIRING_VARIANT_CONSENT -> {
                        // Just Works — auto-accept only for low-security contexts
                        device.setPairingConfirmation(true)
                        abortBroadcast()
                    }
                }
            }
            BluetoothDevice.ACTION_BOND_STATE_CHANGED -> {
                val bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
                when (bondState) {
                    BluetoothDevice.BOND_BONDED -> onBondingComplete(device)
                    BluetoothDevice.BOND_NONE -> onBondingFailed(device)
                }
            }
        }
    }
}

// In your Activity/Fragment:
private fun showNumericConfirmationDialog(device: BluetoothDevice, passkey: Int) {
    // Show dialog: "Does the code on your device show: 123456?"
    // On confirm:
    AlertDialog.Builder(this)
        .setTitle("Confirm Pairing Code")
        .setMessage("Does your device display: %06d".format(passkey))
        .setPositiveButton("Yes") { _, _ -> device.setPairingConfirmation(true) }
        .setNegativeButton("No") { _, _ -> device.setPairingConfirmation(false) }
        .show()
}

Stage 5: DHKey Check — The MITM Prevention Stage

Stage 5 is where the cryptographic guarantee happens. Both devices independently compute the Diffie-Hellman Key from the exchanged public keys and the nonces from Stage 4. Each device then sends a DHKey check value — a MAC over the nonces and IO capabilities. An attacker who intercepted the public keys cannot compute the correct DHKey without knowing the private keys, so they cannot produce a valid DHKey check. This is what the PERMISSION_READ_ENCRYPTED_MITM permission enforces at the GATT layer.

Production Insight

Android's Bluetooth stack handles Stages 3–6 automatically once you've initiated bonding and configured ENCRYPTED_MITM permissions. Your application code is responsible for Stages 1–2 (advertising and capability declaration) and Stage 7 (responding to PAIRING_VARIANT_PASSKEY_CONFIRMATION). Everything else happens in the stack.

Production Lessons: Android OEM Quirks in Pairing

  • Samsung One UI 4+: ACTION_PAIRING_REQUEST sometimes fires twice for the same device. Guard with a debounce flag.
  • Xiaomi MIUI: System dialog overrides your abortBroadcast() on some firmware. Register the receiver with FOREGROUND_SERVICE priority as a workaround.
  • Android 12+ (S): BLUETOOTH_CONNECT permission is required at runtime before initiating pairing. Missing this permission causes silent pairing failure with no callback.
  • Re-bonding after forgetting: Always call removeBond() via reflection before re-pairing a previously bonded device. Stale bond state causes authentication failure.

Production Result

The seven-stage LE Secure Connections implementation eliminated all MITM attack surface for the automotive diagnostic device. Security audit found zero exploitable pairing vulnerabilities across firmware versions v1.0 through v3.2.

Topics

BLE SecurityLE Secure ConnectionsMITMPairingAndroidKotlinBluetooth

Working on a similar challenge?

Let's discuss the architecture before you build.

BLE system design, OTA reliability, and connected product engineering — this is what TEJVON does every day.

Book a Technical Consultation