Hard-coding your WiFi SSID and password into firmware is a beginner habit that creates real problems in production: the firmware breaks the moment credentials change, you can't ship devices to customers who have different networks, and credentials end up committed to git history. This tutorial shows the professional alternative — provision WiFi credentials over BLE at first boot, persist them to NVS (non-volatile storage), and enable OTA updates so you can push new firmware wirelessly without ever touching a USB cable again.

// What you'll build: An ESP32 that advertises a BLE GATT service with two writable characteristics (SSID and password). A phone app writes credentials once; the ESP32 saves them to NVS with Preferences, connects to WiFi, and starts ArduinoOTA. On all subsequent boots it reads NVS and reconnects automatically. OTA updates are pushed from Arduino IDE over the local network.
FULL BUILD DEMO ON YOUTUBE

Watch the complete build on the ESPSTACK YouTube channel →

// AD SLOT — IN-CONTENT RESPONSIVE

Prerequisites

ComponentNotes
ESP32 DevKitAny variant — must have WiFi + BLE
Smartphone (iOS or Android)For BLE provisioning; any BLE GATT app works (e.g., nRF Connect)
Arduino IDE 2.xWith ESP32 by Espressif board package
ArduinoOTA libraryBuilt into the ESP32 core — no separate install

Step 1 — NVS Credentials: Read on Boot

On every boot, check NVS for stored credentials. If they exist, skip BLE provisioning and connect directly. This makes the provisioning flow a one-time setup, not a burden on every reboot.

#include <Preferences.h>
#include <WiFi.h>
#include <ArduinoOTA.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>

Preferences prefs;

String savedSSID, savedPass;

bool loadCredentials() {
  prefs.begin("wifi-cfg", true); // read-only
  savedSSID = prefs.getString("ssid", "");
  savedPass = prefs.getString("pass", "");
  prefs.end();
  return savedSSID.length() > 0;
}

void saveCredentials(const String &ssid, const String &pass) {
  prefs.begin("wifi-cfg", false);
  prefs.putString("ssid", ssid);
  prefs.putString("pass", pass);
  prefs.end();
}

Step 2 — BLE GATT Provisioning Service

Create a BLE server with one service and two writable characteristics: one for SSID and one for password. When both are written the callback saves them and triggers a reconnect. The UUID values below are arbitrary — generate your own with uuidgen for production use.

#define SERVICE_UUID   "4fafc201-1fb5-459e-8fcc-c5c9c331914b"
#define CHAR_SSID_UUID "beb5483e-36e1-4688-b7f5-ea07361b26a8"
#define CHAR_PASS_UUID "cba1d466-344c-4be3-ab3f-189f80dd7518"

volatile bool credentialsReceived = false;
String bleSSID, blePass;

class WiFiProvCallback : public BLECharacteristicCallbacks {
  BLECharacteristic *pSSIDChar, *pPassChar;
public:
  WiFiProvCallback(BLECharacteristic *s, BLECharacteristic *p)
    : pSSIDChar(s), pPassChar(p) {}

  void onWrite(BLECharacteristic *pChar) override {
    std::string val = pChar->getValue();
    if (pChar == pSSIDChar) bleSSID = String(val.c_str());
    if (pChar == pPassChar)  blePass = String(val.c_str());

    // Save and reconnect once both fields are written
    if (bleSSID.length() > 0 && blePass.length() > 0) {
      saveCredentials(bleSSID, blePass);
      credentialsReceived = true;
    }
  }
};

BLEServer *pServer = nullptr;

void startBLEProvisioning() {
  BLEDevice::init("ESP32_Config");
  pServer = BLEDevice::createServer();
  BLEService *pSvc = pServer->createService(SERVICE_UUID);

  BLECharacteristic *pSSID = pSvc->createCharacteristic(
    CHAR_SSID_UUID,
    BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_READ
  );
  BLECharacteristic *pPass = pSvc->createCharacteristic(
    CHAR_PASS_UUID,
    BLECharacteristic::PROPERTY_WRITE
  );

  WiFiProvCallback *cb = new WiFiProvCallback(pSSID, pPass);
  pSSID->setCallbacks(cb);
  pPass->setCallbacks(cb);

  pSvc->start();
  pServer->getAdvertising()->start();
  Serial.println("BLE provisioning started — waiting for credentials...");
}

Step 3 — Connect to WiFi

After credentials are received or loaded from NVS, connect to WiFi with a timeout. If connection fails (wrong password, network unreachable) fall back to BLE provisioning to allow the user to re-enter credentials.

bool connectWiFi(const String &ssid, const String &pass) {
  WiFi.begin(ssid.c_str(), pass.c_str());
  Serial.print("Connecting to WiFi");
  uint32_t t0 = millis();
  while (WiFi.status() != WL_CONNECTED) {
    if (millis() - t0 > 15000) {
      Serial.println("\nWiFi timeout!");
      return false;
    }
    delay(500);
    Serial.print(".");
  }
  Serial.printf("\nConnected! IP: %s\n", WiFi.localIP().toString().c_str());
  return true;
}
// AD SLOT — IN-CONTENT RESPONSIVE

Step 4 — ArduinoOTA Setup

Once WiFi is up, initialize ArduinoOTA. It listens on port 3232 (mDNS advertised) so Arduino IDE shows the device under Tools → Port as a network port. Optionally set an OTA password to prevent unauthorized uploads on shared networks.

void setupOTA() {
  ArduinoOTA.setHostname("ESP32-IoT");
  // ArduinoOTA.setPassword("secure_ota_pass"); // optional

  ArduinoOTA.onStart([]() {
    Serial.println("OTA Start");
  });
  ArduinoOTA.onEnd([]() {
    Serial.println("\nOTA End — rebooting");
  });
  ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
    Serial.printf("OTA Progress: %u%%\r", (progress * 100) / total);
  });
  ArduinoOTA.onError([](ota_error_t error) {
    Serial.printf("OTA Error[%u]: ", error);
    if      (error == OTA_AUTH_ERROR)    Serial.println("Auth Failed");
    else if (error == OTA_BEGIN_ERROR)   Serial.println("Begin Failed");
    else if (error == OTA_CONNECT_ERROR) Serial.println("Connect Failed");
    else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed");
    else if (error == OTA_END_ERROR)     Serial.println("End Failed");
  });

  ArduinoOTA.begin();
  Serial.println("ArduinoOTA ready");
}

Step 5 — Full setup() and loop()

void setup() {
  Serial.begin(115200);

  if (loadCredentials()) {
    Serial.printf("Found saved SSID: %s\n", savedSSID.c_str());
    if (!connectWiFi(savedSSID, savedPass)) {
      // Clear stale credentials and re-provision
      saveCredentials("", "");
      startBLEProvisioning();
    } else {
      setupOTA();
    }
  } else {
    startBLEProvisioning();
  }
}

void loop() {
  if (credentialsReceived) {
    credentialsReceived = false;
    BLEDevice::deinit(true); // stop BLE to free RAM for WiFi
    if (connectWiFi(bleSSID, blePass)) {
      setupOTA();
    }
  }
  ArduinoOTA.handle(); // must be called every loop iteration
}
// Memory note: On ESP32, BLE and WiFi share the same radio but compete for heap. Call BLEDevice::deinit(true) after provisioning is complete to reclaim ~80 KB of heap before starting WiFi-heavy operations.

Troubleshooting

SymptomLikely CauseFix
BLE not visible after WiFi connectNot expected — BLE is stopped after provisioningThis is correct behaviour; restart ESP32 to re-provision
OTA port not visible in IDEmDNS not resolving or firewall blocking port 3232Ensure phone/PC is on same subnet; disable host firewall temporarily
NVS values lost after firmware updateNVS partition erased by full flashUse "Minimal SPIFFS" partition scheme which preserves NVS between OTA uploads
WiFi connects but OTA upload fails mid-transferWeak WiFi signal or packet lossMove closer to AP; increase OTA timeout in ArduinoOTA source if needed
Wrong credentials stored, can't provision againNVS has stale valuesAdd a "factory reset" button that calls prefs.clear() and restarts

Next Steps

  • Add a captive-portal fallback using WiFiManager library for users who prefer a browser-based setup over a custom BLE app.
  • Implement a "factory reset" button (GPIO + hold 5 s) that clears NVS credentials and re-enters BLE provisioning mode.
  • Host the OTA binary on an HTTPS server and use HTTPUpdate to pull firmware automatically when a new version is published.
  • Add a BLE status notify characteristic that pushes the ESP32's IP address back to the phone app after successful WiFi connection.