Skip to content

ESP32 BLE Passkey Pairing, Bonding, and Encryption

The default ESP32 BLE example from Espressif ships with zero authentication. Any device in range can connect, read, and write your characteristics. For a product controlling physical actuators, that is not acceptable. This post shows how to add passkey pairing, MITM protection, and bonding to an ESP32 BLE server using the Arduino BLE stack, drawn from a real motor controller firmware.

Prerequisites

  • ESP32 dev board with the Arduino framework via PlatformIO or Arduino IDE
  • A working BLE server with at least one service and characteristic. Understand BLEDevice, BLEServer, BLEService, and BLECharacteristic before continuing.
  • nRF Connect (iOS or Android) for verifying pairing behavior

Stack used in this post

PlatformIO · espressif32 platform · Arduino framework · ESP-IDF BLE stack via BLEDevice.h

Background

BLE security covers three independent concerns: encryption, authentication, and bonding. Most tutorials address none of them. The naive BLE server is discoverable, connectable, and writable by any peer in range, including other apps on a connected phone.

The ESP32 Arduino BLE library exposes BLEDevice::setEncryptionLevel and BLESecurity to configure all three. The interaction between SecurityCallbacks, BLEServerCallbacks, and the underlying esp_ble_* API is not documented in one place, and the callback firing order matters.

Pairing is not bonding

Pairing authenticates a single connection. Bonding persists the link key so re-authentication is not required on every reconnect. Without bonding, the user sees a PIN prompt on every connection.

Implementation

1. Set the encryption level before init

These calls must happen before BLEDevice::init. Changes after init have no effect.

src/main.cpp
#define PASSKEY 123456

void setup() {
    BLEDevice::init("ATOMBRICK_SERVER");
    BLEDevice::setEncryptionLevel(ESP_BLE_SEC_ENCRYPT);
    BLEDevice::setSecurityCallbacks(new SecurityCallbacks());
}

2. Implement SecurityCallbacks

All five methods matter. Which ones fire depends on the IO capability configured in step 3.

src/main.cpp
class SecurityCallbacks : public BLESecurityCallbacks {
    uint32_t onPassKeyRequest() override {
        return PASSKEY; // Sent to the peer during pairing
    }

    void onPassKeyNotify(uint32_t passkey) override {
        Serial.printf("Pairing PIN: %06d\n", passkey);
    }

    bool onConfirmPIN(uint32_t passkey) override {
        return passkey == PASSKEY; // Numeric comparison pairing
    }

    bool onSecurityRequest() override {
        return true;
    }

    void onAuthenticationComplete(esp_ble_auth_cmpl_t cmpl) override {
        authenticated = cmpl.success;
        Serial.printf("Auth %s\n", authenticated ? "OK" : "FAILED");
    }
};

onPassKeyRequest fires when this device generates the passkey. onConfirmPIN handles numeric comparison pairing, where both sides display the same number. Which path is taken depends on ESP_IO_CAP_OUT vs ESP_IO_CAP_IO in step 3.

3. Configure BLESecurity with consistent flags

This is where most implementations fail silently: the auth mode, IO capability, and PIN method must be consistent with each other.

src/main.cpp
1
2
3
4
5
BLESecurity *pSecurity = new BLESecurity();
pSecurity->setAuthenticationMode(ESP_LE_AUTH_BOND | ESP_LE_AUTH_REQ_MITM);
pSecurity->setCapability(ESP_IO_CAP_OUT);
pSecurity->setStaticPIN(PASSKEY);
pSecurity->setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK);

ESP_LE_AUTH_BOND persists the link key. ESP_LE_AUTH_REQ_MITM requires man-in-the-middle protection, which forces passkey entry rather than "just works" pairing. ESP_IO_CAP_OUT declares that the device can display a passkey but cannot accept input, so the peer must enter it.

IO capability mismatch causes silent security downgrade

Setting MITM in the auth mode alongside ESP_IO_CAP_NONE silently falls back to "just works" pairing because there is no way to complete MITM-protected exchange without user interaction. The connection succeeds, authenticated becomes true, and you have no MITM protection at all.

4. Resume advertising on disconnect

The ESP32 stops advertising after the first connection and does not resume automatically.

src/main.cpp
class ConnectionServerCallbacks : public BLEServerCallbacks {
    void onConnect(BLEServer *pServer) override {
        deviceConnected = true;
        authenticated = true;
    }

    void onDisconnect(BLEServer *pServer) override {
        deviceConnected = false;
        pServer->getAdvertising()->start();
    }
};

5. Gate characteristic writes on authentication

The characteristic callbacks can fire before authentication completes in edge cases. Guard every sensitive handler explicitly.

src/main.cpp
class MotorOneCallbacks : public BLECharacteristicCallbacks {
    void onWrite(BLECharacteristic *pCharacteristic) override {
        if (!authenticated) return;

        std::string value = pCharacteristic->getValue();
        if (!value.empty()) {
            uint8_t command = value[0];
            int8_t  data    = static_cast<int8_t>(value[1]);
            processMotorCommand(motorA, command, data);
            pCharacteristic->notify();
        }
    }
};

The authenticated flag is set by onAuthenticationComplete. Without this guard, a peer that connects but fails pairing can still write characteristics before the ESP32 closes the connection.

6. Persist the device name across power cycles

Changing the advertised name at runtime requires re-initialising the BLE stack. Preferences (backed by ESP32 NVS flash) keeps the name across reboots.

src/main.cpp
class DeviceNameCallbacks : public BLECharacteristicCallbacks {
    void onWrite(BLECharacteristic *pCharacteristic) override {
        std::string newName = pCharacteristic->getValue();
        if (!newName.empty()) {
            BLEDevice::deinit();
            delay(100);
            BLEDevice::init(newName.c_str());
            preferences.putString("name", newName.c_str());
            pServer->getAdvertising()->start();
        }
    }
};

deinit tears down the stack cleanly. init brings it back with the new name. The previously configured security settings survive the cycle.

In setup, restore the stored name before BLEDevice::init:

src/main.cpp
preferences.begin("bt-config", false);
String storedName = preferences.getString("name", DEVICE_NAME);
BLEDevice::init(storedName.c_str());

Testing and Verification

Flash the firmware and verify with nRF Connect:

  1. The device appears in the scan list under your advertised name.
  2. Tapping Connect triggers a pairing dialog. The phone requests the PIN.
  3. Enter 123456. The connection establishes.
  4. Disconnect and reconnect. No PIN prompt appears (bonding worked).
  5. A second phone must re-pair from scratch.
[Serial Monitor]
Starting Secure BLE Server...
BLE Server Ready. Pairing PIN: 123456
Device Connected
Auth OK

If you see Auth FAILED immediately after connect, the IO capability and auth mode flags are inconsistent. Also check whether the phone has a cached bond from a previous insecure pairing. Clear the bond on both sides and try again.

Pitfalls

Stale bonds break reconnection after firmware changes

If the device is re-flashed with a different passkey, the phone still holds the old bond key and tries to reconnect without a PIN. The ESP32 rejects the key and the connection drops silently. Clear the pairing from the phone's Bluetooth settings before re-testing.

BLE2902 descriptor is required for NOTIFY

Any characteristic with PROPERTY_NOTIFY must have a BLE2902 descriptor attached. Without it, notify() sends to nobody and logs no error.

src/main.cpp
pCharacteristic->addDescriptor(new BLE2902());

Production Considerations

A static PIN is sufficient for development. In a shipped product, derive the PIN from the device serial number and print it on the label. This gives each unit a unique PIN without adding a display to the hardware.

Per-characteristic access control via setAccessPermissions(ESP_GATT_PERM_READ_ENCRYPTED | ESP_GATT_PERM_WRITE_ENCRYPTED) rejects reads and writes at the GATT layer unless the link is encrypted, adding a second layer of defence independent of the authenticated flag.

Wrapping Up

The security configuration requires consistent flags across setEncryptionLevel, setAuthenticationMode, setCapability, and setStaticPIN. Any mismatch degrades silently to a weaker mode: the connection succeeds but without MITM protection. Verify in nRF Connect that the pairing type shown is "Passkey Entry", not "Just Works", before shipping anything.

The next step is per-characteristic access control with setAccessPermissions, which enforces encryption requirements at the GATT layer rather than relying on the application-level authenticated flag.


Comments