Difficulty: Intermediate | Time: 45 Minutes | Target Board: ESP32-WROOM-32E (DevKitC V4, 38-pin)

The Direct Answer: What is an ESP32 Web Config Portal?

An ESP32 web config portal is a captive network interface that allows end-users to input WiFi credentials and custom device parameters (like MQTT broker IPs or API keys) via a smartphone or laptop browser, without hardcoding them into the firmware. When the ESP32 boots and cannot find a known network—or when a physical config button is pressed—it spins up an Access Point (AP), intercepts DNS requests to force a browser popup, and serves a configuration form. Once submitted, the credentials are saved to non-volatile storage (NVS) and the device reboots into Station (STA) mode.

This eliminates the need to recompile firmware for every deployment and prevents the security risk of shipping hardcoded network passwords in your source code.

Provisioning Methods Compared

Before wiring up the board, you need to choose the right provisioning architecture. Here is how the standard ESP32 web config approach stacks up against Espressif's native alternatives for a 2026 deployment.

Method Blocking/Non-Blocking Custom Params Flash Wear Best For
WiFiManager (Captive Portal) Blocking (during setup) Yes (HTML forms) Low (if using NVS) Consumer IoT, DIY smart home
ESPAsyncWebServer (Custom UI) Non-Blocking Yes (Full SPA/React) Medium (LittleFS) Complex dashboards, industrial
SmartConfig (Espressif App) Blocking No (WiFi only) Low Mass production, no UI needed
BLE Provisioning Non-Blocking Yes (via BLE GATT) Low High-security enterprise IoT

Source: Espressif Arduino Core Documentation and tzapu WiFiManager Repository.

Hardware & Pin Mapping

We are targeting the ESP32-WROOM-32E (DevKitC V4, 38-pin variant). The 'E' variant features an updated 4MB flash layout and improved RF matching over the obsolete V1 silicon. Avoid the original 30-pin DevKit V1 boards for new designs; their strapping pin layouts cause chronic boot-loop issues when wiring buttons.

Parts List

  • MCU: ESP32-WROOM-32E DevKitC V4 (38-pin, USB-C)
  • Switch: 6x6mm tactile momentary pushbutton
  • Resistor: 10kΩ (optional, only if your board lacks an internal pull-up on GPIO 0)
  • LED: 5mm through-hole LED with 220Ω current-limiting resistor

Pin Mapping Table

ESP32 GPIO Component Function Notes / Constraints
GPIO 0 Tactile Switch Config Trigger (BOOT) Must be HIGH at boot. Switch pulls to GND.
GPIO 2 LED + 220Ω Resistor Status Indicator Also a strapping pin; safe to use as output post-boot.
GND Switch & LED Cathode Common Ground Ensure solid breadboard contact to avoid floating inputs.
⚠️ Bench Warning: GPIO 0 is a critical strapping pin. If your tactile switch is wired backwards, or if you use a long wire that acts as an antenna picking up EMI, the ESP32 will read GPIO 0 as LOW during power-on and enter the serial bootloader instead of running your firmware. Keep the wire from GPIO 0 to the switch under 3 inches.

The Code: Non-Blocking Config with NVS

This implementation uses the WiFiManager library (v2.0.17+) alongside the ESP32's native Preferences.h (NVS). We avoid LittleFS/SPIFFS here because NVS requires no custom partition table modifications, saving you from the most common flash-wear and boot-partition errors.

Required Libraries (via Arduino Library Manager):

  • WiFiManager by tablatronix (v2.0.17 or newer)
  • ArduinoJson by Benoit Blanchon (v6.21+ or v7) - Included in this sketch for future extensibility, though NVS handles the raw strings here.
#include <WiFi.h>
#include <WiFiManager.h>
#include <Preferences.h>

// --- PIN DEFINITIONS ---
#define CONFIG_PIN 0  // BOOT button on DevKitC V4
#define STATUS_LED 2  // Built-in or external LED

// --- GLOBALS ---
Preferences prefs;
char mqtt_server[40] = "192.168.1.100"; // Default fallback
bool shouldSaveConfig = false;

// --- CALLBACK: Triggered when user hits 'Save' in the web portal ---
void saveConfigCallback () {
  shouldSaveConfig = true;
}

void setup() {
  Serial.begin(115200);
  delay(100); // Allow serial buffer to settle
  
  pinMode(CONFIG_PIN, INPUT_PULLUP);
  pinMode(STATUS_LED, OUTPUT);
  digitalWrite(STATUS_LED, LOW);

  Serial.println("\n--- ESP32 Web Config Boot ---");

  // 1. Initialize NVS (Non-Volatile Storage)
  prefs.begin("iot-config", false); 
  prefs.getString("mqtt_ip", mqtt_server, 40);
  Serial.print("Loaded MQTT Server: ");
  Serial.println(mqtt_server);

  // 2. Setup WiFiManager
  WiFiManager wm;
  
  // Add custom parameter for the web config portal
  WiFiManagerParameter custom_mqtt("mqtt_ip", "MQTT Broker IP", mqtt_server, 40);
  wm.addParameter(&custom_mqtt);
  
  // Bind the save callback
  wm.setSaveConfigCallback(saveConfigCallback);
  
  // Set portal timeout (seconds) to prevent infinite AP mode
  wm.setConfigPortalTimeout(180);

  // 3. Trigger Logic: AutoConnect vs Forced Config Portal
  if (digitalRead(CONFIG_PIN) == LOW) {
    Serial.println("Config button pressed. Forcing Web Config Portal...");
    digitalWrite(STATUS_LED, HIGH); // Solid ON = Config Mode
    
    // Blocks here until user configures or timeout occurs
    if (!wm.startConfigPortal("ESP32-WebConfig", "setup1234")) {
      Serial.println("[ERROR] Config portal timeout or failure. Restarting.");
      ESP.restart();
    }
  } else {
    Serial.println("Attempting AutoConnect...");
    digitalWrite(STATUS_LED, HIGH);
    delay(100);
    digitalWrite(STATUS_LED, LOW); // Flash to show activity
    
    if (!wm.autoConnect("ESP32-WebConfig", "setup1234")) {
      Serial.println("[ERROR] AutoConnect failed. Restarting.");
      ESP.restart();
    }
  }

  // 4. Save Custom Parameters if the form was submitted
  if (shouldSaveConfig) {
    strncpy(mqtt_server, custom_mqtt.getValue(), 40);
    prefs.putString("mqtt_ip", mqtt_server);
    Serial.println("Configuration saved to NVS.");
    shouldSaveConfig = false;
  }

  // 5. Connected State
  Serial.print("Connected! IP: ");
  Serial.println(WiFi.localIP());
  
  // Blink LED to indicate successful connection
  for(int i=0; i<5; i++) {
    digitalWrite(STATUS_LED, HIGH); delay(100);
    digitalWrite(STATUS_LED, LOW);  delay(100);
  }
}

void loop() {
  // Your main application logic (MQTT, sensor reads) goes here.
  // Avoid using delay() in the main loop; use millis() for timing.
}

Debugging: Exact Errors and the "First Three" Checklist

When an ESP32 web config portal fails, it usually happens at the intersection of RF physics, DNS hijacking, and flash memory allocation. Before rewriting your code, run through this diagnostic sequence.

The "First Three" Things to Check

  1. Strapping Pin Conflicts: If the board boots into the serial bootloader (you see waiting for download in the Serial Monitor), GPIO 0 is being held LOW. Check your tactile switch wiring and ensure no external peripherals are pulling GPIO 0, GPIO 2, or GPIO 12 down during the 50ms boot window.
  2. Captive Portal DNS Interception: Modern smartphones (especially iOS 16+ and Android 14+) aggressively block captive portal DNS hijacking if the AP doesn't serve a valid HTTPS certificate. If the popup doesn't appear, manually open a browser and navigate to http://192.168.4.1 (the default ESP32 AP IP).
  3. NVS Namespace Corruption: If the ESP32 continuously reboots when loading credentials, the NVS partition may be corrupted from a previous firmware flash that used a different partition scheme. Use the Arduino IDE "Erase All Flash Before Upload" option once to clear the slate.

Exact Error Strings & Ranked Causes

Error 1: [WiFiManager] AutoConnect: FAILED
  • Cause A (80%): You are trying to connect to a 5GHz WiFi network. The ESP32-WROOM-32E is strictly a 2.4GHz (802.11 b/g/n) radio. It cannot physically see 5GHz SSIDs.
  • Cause B (15%): Router MAC filtering or WPA3-Enterprise incompatibility. The ESP32 Arduino core supports WPA2-Personal natively; WPA3 requires specific core configurations.
  • Fix: Ensure your router broadcasts a 2.4GHz band with WPA2-PSK (AES) security.
Error 2: Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1)
  • Cause (95%): You placed a blocking function (like delay(1000), a synchronous HTTPClient call, or a bit-banged I2C read) inside the saveConfigCallback or the WiFiManager event loop.
  • Fix: The callback must only set a boolean flag (as done in the code above: shouldSaveConfig = true;). Handle all NVS writes and network calls in the main setup() flow after the portal closes.
Error 3: E (1234) nvs: nvs_open_from_partition failed: NOT_FOUND
  • Cause (90%): You selected a partition scheme in the Arduino IDE Tools menu that lacks an NVS partition (e.g., "Huge APP (3MB No OTA/1MB SPIFFS)" sometimes misconfigures NVS boundaries on 4MB boards).
  • Fix: Set Tools > Partition Scheme to "Default 4MB with spiffs (1.2MB APP/1.5MB SPIFFS)". This guarantees the standard NVS partition is intact.

Extending and Simplifying the Build

Depending on your production timeline, you may need to scale this architecture up or down.

How to Simplify (WiFi Only)

If you do not need custom parameters like MQTT IPs or API keys, strip out Preferences.h and WiFiManagerParameter entirely. WiFiManager automatically handles WiFi SSID and Password storage in the background using the ESP32's native WiFi class persistence. Your setup reduces to three lines:

WiFiManager wm;
wm.autoConnect("ESP32-Setup");
// Proceed with standard WiFi.localIP() logic

How to Extend (Full Web Dashboard)

For commercial products requiring a post-configuration dashboard (e.g., viewing real-time sensor graphs or updating OTA firmware via a browser), the captive portal isn't enough. 1. Switch your storage from NVS to LittleFS to host static HTML/CSS/JS files. 2. Implement ESPAsyncWebServer and AsyncTCP to serve those files non-blocking. 3. Use the WiFiManager portal strictly for the initial WiFi handshake, then redirect the user's browser to the ESP32's local mDNS address (e.g., http://mydevice.local) where the AsyncWebServer takes over.

For deeper reading on asynchronous server implementation, refer to the ArduinoJson documentation for parsing REST API payloads sent from your custom web dashboard back to the ESP32.