With ESP32 WiFiManager MQTT parameters, a device you hand to someone else can be configured from a phone: it opens a captive portal, the user picks a Wi-Fi network and fills in the broker settings, and the device remembers everything across reboots. This tutorial builds that flow with the WiFiManager library and Preferences, defaulting to broker.connectmq.com so it works out of the box.
The Problem With Hard-Coded Settings
Most MQTT examples start with lines like const char* WIFI_SSID = "..." and const char* MQTT_PASS = "...". That is fine on your desk, but every new network or credential change means recompiling and reflashing. It also means the firmware binary contains secrets, and you cannot give the same binary to two customers.
A provisioning portal solves this. On first boot, or when the user holds a button, the ESP32 starts its own access point. Connecting to it with a phone opens a web page where the user enters:
- The Wi-Fi network and password.
- The MQTT broker host and port.
- The MQTT client ID, username and password.
- A topic prefix for this device.
The device saves the values to non-volatile storage, joins the Wi-Fi network and connects to the broker. The same binary works for everyone.
Libraries and Hardware
- WiFiManager by tzapu (2.x), installable from the Arduino Library Manager or PlatformIO as
tzapu/WiFiManager. - PubSubClient for MQTT.
- Preferences, which ships with the Arduino-ESP32 core and stores key–value pairs in NVS flash.
- Any ESP32 board. The example uses the BOOT button (GPIO 0) on most DevKits to reopen the portal.
How the ESP32 WiFiManager MQTT Flow Works
| Situation | What happens |
|---|---|
| First boot, no saved Wi-Fi | Portal starts as access point Sensor-Setup-xxxx |
| Saved Wi-Fi reachable | Device connects silently, loads MQTT settings from Preferences |
| Saved Wi-Fi unreachable | Portal opens again after the connection attempt fails |
| BOOT button held for 3 s at start-up | Portal opens on demand so the user can change settings |
| Portal left unused for 3 minutes | Device restarts and tries again, so it never hangs forever |
WiFiManager stores the Wi-Fi credentials itself. The MQTT fields are custom parameters that you add to the portal and persist yourself.
Complete Sketch
#include <WiFi.h>
#include <WiFiManager.h>
#include <Preferences.h>
#include <PubSubClient.h>
const int CONFIG_BUTTON = 0; // BOOT button on most ESP32 DevKits
Preferences prefs;
WiFiClient net;
PubSubClient mqtt(net);
char mqttHost[64] = "broker.connectmq.com";
char mqttPort[6] = "1883";
char mqttClient[48] = "";
char mqttUser[48] = "";
char mqttPass[64] = "";
char topicPrefix[64] = "connectmq/demo/d93f2a";
bool shouldSave = false;
unsigned long lastPublish = 0;
void loadSettings() {
prefs.begin("mqtt", true);
prefs.getString("host", mqttHost, sizeof(mqttHost));
prefs.getString("port", mqttPort, sizeof(mqttPort));
prefs.getString("client", mqttClient, sizeof(mqttClient));
prefs.getString("user", mqttUser, sizeof(mqttUser));
prefs.getString("pass", mqttPass, sizeof(mqttPass));
prefs.getString("prefix", topicPrefix, sizeof(topicPrefix));
prefs.end();
if (strlen(mqttClient) == 0) { // never connect with an empty client ID
snprintf(mqttClient, sizeof(mqttClient), "esp32-%08x", (uint32_t)ESP.getEfuseMac());
}
}
void saveSettings() {
prefs.begin("mqtt", false);
prefs.putString("host", mqttHost);
prefs.putString("port", mqttPort);
prefs.putString("client", mqttClient);
prefs.putString("user", mqttUser);
prefs.putString("pass", mqttPass);
prefs.putString("prefix", topicPrefix);
prefs.end();
}
void runWifiManager(bool forcePortal) {
WiFiManager wm;
WiFiManagerParameter pHost("host", "MQTT host", mqttHost, sizeof(mqttHost) - 1);
WiFiManagerParameter pPort("port", "MQTT port (1883 or 8883)", mqttPort, sizeof(mqttPort) - 1);
WiFiManagerParameter pClient("client", "MQTT client ID", mqttClient, sizeof(mqttClient) - 1);
WiFiManagerParameter pUser("user", "MQTT username (optional)", mqttUser, sizeof(mqttUser) - 1);
WiFiManagerParameter pPass("pass", "MQTT password (optional)", mqttPass, sizeof(mqttPass) - 1,
"type='password'");
WiFiManagerParameter pPrefix("prefix", "Topic prefix", topicPrefix, sizeof(topicPrefix) - 1);
wm.addParameter(&pHost);
wm.addParameter(&pPort);
wm.addParameter(&pClient);
wm.addParameter(&pUser);
wm.addParameter(&pPass);
wm.addParameter(&pPrefix);
wm.setSaveParamsCallback([]() { shouldSave = true; });
wm.setConfigPortalTimeout(180);
char apName[32];
snprintf(apName, sizeof(apName), "Sensor-Setup-%04x", (uint16_t)ESP.getEfuseMac());
bool connected = forcePortal ? wm.startConfigPortal(apName)
: wm.autoConnect(apName);
if (!connected) {
ESP.restart(); // portal timed out: try again from scratch
}
if (shouldSave) {
strlcpy(mqttHost, pHost.getValue(), sizeof(mqttHost));
strlcpy(mqttPort, pPort.getValue(), sizeof(mqttPort));
strlcpy(mqttClient, pClient.getValue(), sizeof(mqttClient));
strlcpy(mqttUser, pUser.getValue(), sizeof(mqttUser));
strlcpy(mqttPass, pPass.getValue(), sizeof(mqttPass));
strlcpy(topicPrefix, pPrefix.getValue(), sizeof(topicPrefix));
saveSettings();
loadSettings(); // re-applies the client ID fallback
}
}
bool buttonHeld(unsigned long ms) {
pinMode(CONFIG_BUTTON, INPUT_PULLUP);
unsigned long start = millis();
while (digitalRead(CONFIG_BUTTON) == LOW) {
if (millis() - start > ms) return true;
delay(10);
}
return false;
}
void connectMqtt() {
while (!mqtt.connected()) {
bool ok = strlen(mqttUser) > 0
? mqtt.connect(mqttClient, mqttUser, mqttPass)
: mqtt.connect(mqttClient);
if (ok) {
String cmd = String(topicPrefix) + "/cmd";
mqtt.subscribe(cmd.c_str());
} else {
Serial.printf("MQTT failed state=%d, retrying\n", mqtt.state());
delay(5000);
}
}
}
void setup() {
Serial.begin(115200);
loadSettings();
delay(200);
runWifiManager(buttonHeld(3000));
mqtt.setServer(mqttHost, atoi(mqttPort));
Serial.printf("Broker %s:%s as %s\n", mqttHost, mqttPort, mqttClient);
}
void loop() {
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
if (millis() - lastPublish > 10000) {
lastPublish = millis();
String topic = String(topicPrefix) + "/telemetry";
String payload = "{\"uptime\":" + String(millis() / 1000) + "}";
mqtt.publish(topic.c_str(), payload.c_str());
}
}
Walking Through the Portal
- Flash the sketch and open the serial monitor.
- On your phone, join the Wi-Fi network named
Sensor-Setup-xxxx. Most phones open the captive portal automatically; otherwise browse to192.168.4.1. - Tap Configure WiFi, pick your network and enter its password.
- Leave the defaults to use the public area of broker.connectmq.com, or enter your own settings, then save.
- The device joins your network, connects to the broker and starts publishing to
<prefix>/telemetry.
Verify from a computer:
mosquitto_sub -h broker.connectmq.com -p 1883 -i pc-d93f2a -t 'connectmq/demo/d93f2a/#' -v
Each device that keeps the default prefix publishes to the same topic, so if you set up several, give each one its own prefix in the portal.
Details That Make It Reliable
Load before you build the portal. The custom parameters are created with the current values as defaults, so the portal shows what is stored, not a blank form. Users editing one field do not lose the others.
Never connect with an empty client ID. If the user clears the field, loadSettings() falls back to an ID derived from the chip's MAC. An empty or duplicated client ID is one of the top reasons devices fail to connect or kick each other off; see why an ESP32 cannot connect to an MQTT broker for the full list.
Use the portal timeout. Without setConfigPortalTimeout(), a device whose router is temporarily down sits in portal mode forever. With a timeout it restarts and retries the saved network.
Store numbers as strings in the portal, convert once. WiFiManager parameters are text fields. Keep the port as a string in storage and convert with atoi() when configuring the client.
Hide the password field. The extra argument type='password' masks the MQTT password in the portal. The value is still sent over the open access point in plain HTTP, so only provision in a trusted location.
Validating What Users Type
A captive portal accepts whatever the user enters, so the firmware has to cope with mistakes. A few checks after saving prevent a device from bricking itself with bad settings:
- Port: accept only values between 1 and 65535. If
atoi()returns 0 because the field contained text, fall back to 1883. - Host: trim spaces and reject an empty string. Users often paste a URL such as
mqtt://broker.connectmq.com; strip the scheme and anything after the host name. - Topic prefix: remove a trailing slash and reject wildcards (
+and#), which are not allowed in publish topics. - Credentials: if a username is set but the password is empty, warn on the serial console, because the broker will refuse the connection.
When the MQTT connection fails repeatedly with a positive mqtt.state() code (bad credentials or not authorised), it is usually a settings problem rather than a network one. A good pattern is to count those failures and reopen the portal automatically after several in a row, so the user can correct the values without needing a button or a serial cable.
Adding TLS for Production Brokers
Port 1883 is plain text and fine for the public test area. For a private broker, switch to WiFiClientSecure when the user chooses port 8883:
#include <WiFiClientSecure.h>
WiFiClientSecure secureNet;
void configureTransport() {
if (atoi(mqttPort) == 8883) {
secureNet.setCACert(ISRG_ROOT_X1); // Let's Encrypt root, stored as a PEM string
mqtt.setClient(secureNet);
} else {
mqtt.setClient(net);
}
mqtt.setServer(mqttHost, atoi(mqttPort));
}
Call configureTransport() instead of the plain setServer() line in setup(). With ConnectMQ, users then enter the client ID, username and password generated for their broker in the portal (YOUR_CLIENT_ID, YOUR_USERNAME, YOUR_PASSWORD). The client ID is bound to those credentials, so it has to be entered exactly as issued. The ESP32 MQTT SSL/TLS guide explains how to embed the root certificate.
Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
| Portal never appears | Saved Wi-Fi still connects | Hold the BOOT button during reset, or call wm.resetSettings() once |
| Parameters do not save | Save callback not triggered | Use setSaveParamsCallback() and press Save on the portal page |
| Values truncated | Field length smaller than input | Increase the buffer size and the parameter length |
| Connects to Wi-Fi but not MQTT | Wrong port for the transport, or wrong credentials | Check mqtt.state() and the port/TLS combination |
| Phone does not open the portal | Captive detection blocked | Browse manually to 192.168.4.1 |
Where to Go Next
Once settings are configurable, the natural next steps are remote updates and status reporting: ESP32 OTA updates triggered over MQTT shows how to ship new firmware without USB, and the ESP32 IoT tutorial puts provisioning into a complete device project.
Provision Devices for ConnectMQ
The default settings point at the free public MQTT broker, so a fresh device works the moment it joins Wi-Fi. For deployments, create a private broker on ConnectMQ and enter its credentials in the portal. ConnectMQ is a managed MQTT broker, so the people configuring devices never need to run a server. Start on the free Serverless plan at app.connectmq.com, no credit card required.
Frequently Asked Questions
Where does WiFiManager store the Wi-Fi password?
The ESP32 Wi-Fi driver stores the network credentials in NVS flash. The MQTT fields are stored separately in this sketch using Preferences under the mqtt namespace.
How do I reopen the WiFiManager portal later?
Hold the BOOT button for three seconds during start-up in this sketch, which calls startConfigPortal(). You could also trigger it from an MQTT command, as long as the device is still reachable.
Can I use WiFiManager with AsyncMqttClient?
Yes. WiFiManager only handles provisioning. Once it returns, use the stored values to configure whichever MQTT client you prefer.
Is the captive portal secure?
The portal runs as an open access point over plain HTTP, so anyone nearby could observe the values during setup. Provision in a trusted place, and consider setting a password on the setup access point.



