Discover how BLE bonding and RPA work, investigate repeated pairing issues on Android, and identify the root cause across Android and ESP32/NimBLE. Learn how IRK exchange, RPA resolution, and identity-based bond storage ensure reliable reconnections.
This article was written with the assistance of AI and reviewed by our engineering team.
BLE bonding and RPA handling in firmware for reliable device identification and connection management.
We encountered a BLE bonding issue where some Android phones were repeatedly asked to pair with the device after reconnecting, even though the device had already been bonded.
The issue was first observed on newer Android devices, including the Samsung S25 and Google Pixel. After disconnecting and attempting to reconnect, the BLE connection could fail with CONNECTION_TERMINATED_BY_LOCAL_HOST, followed by another pairing prompt from Android. Interestingly, the same behaviour did not occur consistently across all devices or after every disconnect/reconnect cycle.
To identify the cause, we investigated the issue at both the Android application and ESP32 firmware levels. The investigation focused on BLE address types, Resolvable Private Addresses (RPA), identity addresses, stored bonding information, encryption state, and BLE disconnect reasons.
The investigation ultimately identified that the ESP32/NimBLE firmware was associating the Android device’s bonding information with a temporary RPA rather than its permanent BLE identity address. This caused the previously stored bond information to become unusable when the phone changed its RPA.
This article explains how BLE bonding and RPA work, how the issue was traced through Android logs and firmware behaviour, and how the bonding implementation was corrected in the ESP32 (NimBLE) firmware.
The first connection and bonding always succeeded, the failure appeared only on the second and later reconnections.
Test sequence:
Expected result at step 4: the phone and device re-encrypt the link using the stored keys, with no pairing prompt. Actual result: the link dropped with disconnect reason CONNECTION_TERMINATED_BY_LOCAL_HOST (HCI reason 0x16), and the phone asked to bond again.
| Phone used for testing | Behaviour on reconnection |
|---|---|
| Samsung S25 | Disconnect 0x16, pairing prompt every time |
| Google Pixel | Disconnect 0x16, pairing prompt every time |
| Older Android devices | Not seen immediately after disconnect/reconnect |
Bonding is pairing plus storage: both sides save the security keys they exchanged, so the next connection is encrypted straight away without asking the user again.
Pairing creates the keys for one connection. Bonding saves those keys in non-volatile memory on both sides, so they survive a disconnect or power cycle. On the ESP32, NimBLE saves bonds in NVS, on the phone, Android saves them in its Bluetooth settings (the “Paired devices” list).
Pairing runs in three phases:
| Key | Purpose | Used on reconnection for |
|---|---|---|
| LTK | Encrypts the link | Starting encryption without pairing again |
| IRK | Generates and resolves private addresses | Recognising the peer even when its address changes |
| Identity Address | The peer's permanent public or static address | The index under which the bond is stored |
| CSRK | Signs data on an unencrypted link | Rarely used |
The lifecycle between the Android phone and the ESP32 device:
The steps show the full lifecycle: keys are created and exchanged once, saved on both sides, and reused on the next connection. The critical step on reconnection is that the device must find the right bond for the incoming phone, which is where addresses and the IRK come in.
Modern phones never show their real Bluetooth address over the air, they use a Resolvable Private Address that changes regularly, and only bonded devices holding the phone’s IRK can map it back to the real identity.
A fixed Bluetooth address is a tracking beacon. Anyone with a scanner in a shop, station or office could log the same address and follow a person over days. BLE Privacy (Bluetooth 4.2 onward) solves this by replacing the fixed address with a random-looking one that rotates, typically about every 15 minutes and on new connections. iOS has done this for years, and recent Android versions (as on the Samsung S25 and Pixel) use it by default for connections too. Older phones or older Android builds often kept one address for longer, which is why they did not show the fault.
| Type | Changes? | Can be traced to the device? |
|---|---|---|
| Public | Never (IEEE-assigned) | Yes, by anyone |
| Random static | Only at power cycle | Yes, by anyone |
| Resolvable private (RPA) | Every ~15 min / new connection | Only by holders of the IRK |
| Non-resolvable private | Frequently | No |
An RPA is 48 bits: a 24-bit random part (prand, top two bits set to 01) and a 24-bit hash. The phone computes the hash from its secret IRK:
input = 0^104 || prand (104 zero bits concatenated with the 24-bit prand)
output = AES-128(IRK, input) (encrypt using the Identity Resolving Key)
hash = output mod 2^24 (extract 24 bits)
RPA = prand and hash concatenated (48 bits total)
When a connection arrives from an RPA, the device takes the prand part, runs the same AES function with every IRK it has stored, and compares the result with the hash part. A match reveals which bonded phone it is and its permanent identity address. This check can run in the host (NimBLE) or in the controller’s resolving list (LL Privacy), which does it in hardware.
The flow shows why the IRK matters: without it, or if the bond is filed under the RPA, every new RPA looks like a stranger and the device falls to the “pairing required” branch.
The CONNECTION_TERMINATED_BY_LOCAL_HOST reason was the first clue, we then compared what the phone thought it was bonded to with what the ESP32 had actually saved in NVS.
peer_ota_addr) and the identity address ( peer_id_addr).
| Check | Expected | Observed |
|---|---|---|
| Peer address on connect | RPA over the air, resolved to identity | RPA over the air, not resolved |
| Address used as bond key in NVS | Phone's identity (public) address | Phone's private address (RPA) |
| Peer address on reconnect | New RPA, resolved to same identity | New RPA, no matching bond |
| Encryption on reconnect | Success with stored LTK | Failed: key missing |
| Final result | Encrypted link | Phone drops link (0x16), asks to pair again |
What happened between the Android phone and the faulty ESP32 firmware:
The sequence shows the failure chain: the bond exists on both sides, but the ESP32 cannot find it because it is filed under an address the phone no longer uses.
RPA support was not implemented properly in the firmware: the identity keys were not exchanged and resolved, so the bond was saved under the phone’s temporary RPA and could never be found again once the phone rotated its address.
The chain of failure:
Older phones that kept the same address for longer did not rotate between the disconnect and the reconnect, so the stale RPA still matched and the bug stayed hidden. The newest Android devices rotate more aggressively, which exposed it at once.
We enabled identity-key exchange in the NimBLE Security Manager, persisted bonds in NVS keyed by the peer’s identity address, and made every bond lookup use the resolved identity instead of the over-the-air RPA.
The fix had four parts:
Pairing with KEY_DIST_ID
→ Receive phone's IRK + identity addr
→ Save LTK + IRK in NVS (key = identity addr)
→ Add IRK to resolving list
→ Reconnect from new RPA
→ RPA resolved to identity addr
→ Bond found, encrypt with LTK
The fixed flow: the IRK travels once at pairing, and from then on any RPA the phone uses resolves to the same identity and the same stored bond.
After the fix, the phones reconnected on the stored LTK with no pairing prompt and no 0x16 disconnect.
We repeated the original test loop and added the cases that force an address change:
| Test case | Result after fix |
|---|---|
| Pair, disconnect, reconnect immediately | Encrypted on stored LTK, no prompt |
| Reconnect after the phone rotates its RPA (wait > 15 min) | RPA resolved to identity, no prompt |
| Toggle phone Bluetooth off/on, reconnect | No prompt |
| Power-cycle the ESP32, reconnect | Bond loaded from NVS, no prompt |
| Remove bond on phone only, reconnect | Repeat-pairing handled: old bond deleted, fresh pairing once |
In the firmware logs, peer_ota_addr now changed between connections while peer_id_addr stayed the phone’s identity address, and the NVS dump showed one bond per phone under that identity address.
The repeated re-bonding was a firmware privacy bug, not a phone bug: bonds must be stored and looked up by the peer’s identity address, which needs the IRK exchanged during pairing.
Modern Android and iOS rotate RPAs, firmware must never use the over-the-air address as a bond key.
Both sides must share their IRK and identity address during pairing, or RPA resolution cannot work.
Printing the over-the-air address and the resolved identity address on every connection makes this class of bug visible in minutes.
0x16 (local host terminated) after a reconnect usually means a key mismatch, not an RF problem.
Include a wait longer than the RPA timeout and a device power cycle in every bonding test plan, and test on the newest phones first.
Written by Jagan S., System Software Engineer, Krishworks Technology Innovations. Originally published at krishworkstech.com.