If you have ever shipped an ESP32 project to a friend, a client, or even moved it to a different room in your house, you have hit this wall: the WiFi credentials are burned into your firmware. Change networks and the device is a brick until you plug in a USB cable and reflash. That is not a product. That is a prototype.
In this tutorial, you will build a captive portal for the ESP32 — the same mechanism hotels and airports use to get you online. Your device will create its own WiFi access point, serve a configuration page in the browser, let the user type in their SSID and password, then connect automatically. No Arduino IDE, no serial monitor, no code changes.
We will cover two approaches: using the popular WiFiManager library for a fast implementation, and building your own captive portal from scratch for full control. By the end, your ESP32 projects will have a setup experience that feels like a consumer product.
The Problem with Hardcoded Credentials
Here is what most ESP32 tutorials teach you on day one:
const char* ssid = "MyHomeWiFi";
const char* password = "supersecret123";
void setup() {
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
}
}
This works on your bench. It falls apart everywhere else:
| Scenario | What happens |
|---|---|
| You change your router password | Device goes offline permanently |
| You give the device to someone | They need your source code and toolchain |
| You deploy 50 sensors across a building | Each one needs a unique firmware build |
| Your WiFi has a hidden SSID | Good luck configuring that without recompilation |
The fix is to separate credentials from firmware. The device should ask the user for WiFi details at runtime, save them to non-volatile storage, and use them on every subsequent boot.
How a Captive Portal Works
The flow is straightforward:
- Boot — ESP32 checks if it has saved WiFi credentials.
- Try connecting — If credentials exist, attempt to connect (with a timeout).
- Fall back to AP mode — If connection fails or no credentials are saved, the ESP32 creates its own access point (e.g., "ESP32-Setup").
- DNS redirect — A DNS server on the ESP32 resolves every domain to itself, so any URL the user visits redirects to the configuration page.
- Serve the portal — A small web server shows a form with available networks, a password field, and optionally custom parameters.
- Save and restart — The user submits the form, credentials are saved to NVS (Non-Volatile Storage), and the ESP32 reboots into station mode.
This is exactly how smart plugs, smart bulbs, and commercial IoT devices handle first-time setup.
Approach 1: WiFiManager Library
The tzapu/WiFiManager library (ESP32 fork) handles all of the above in about ten lines of code. It is the fastest way to add a captive portal to any project.
Installation
In the Arduino IDE, go to Sketch > Include Library > Manage Libraries and search for "WiFiManager" by tzapu. Install the latest version. For PlatformIO, add this to platformio.ini:
lib_deps =
https://github.com/tzapu/WiFiManager.git
Basic Setup
#include <WiFiManager.h>
void setup() {
Serial.begin(115200);
WiFiManager wm;
// Automatically connect using saved credentials,
// or start the captive portal if none are saved / connection fails
bool connected = wm.autoConnect("ESP32-Setup", "configure123");
if (!connected) {
Serial.println("Failed to connect. Restarting...");
ESP.restart();
}
Serial.println("Connected to WiFi!");
Serial.print("IP Address: ");
Serial.println(WiFi.localIP());
}
void loop() {
// Your application code here
}
That is it. On first boot, you will see an open access point called "ESP32-Setup" (with the password "configure123"). Connect to it from your phone, and a captive portal page opens automatically. Select your network, type the password, hit save. The ESP32 reboots and connects to your WiFi. On every subsequent boot, it connects automatically without showing the portal.
Adding Custom Parameters
Most IoT devices need more than just WiFi credentials. You might need an MQTT broker address, an API key, or a device name. WiFiManager supports custom parameters in the portal form:
#include <WiFiManager.h>
#include <Preferences.h>
Preferences preferences;
void setup() {
Serial.begin(115200);
preferences.begin("app", false);
String savedMqtt = preferences.getString("mqtt_server", "");
String savedApiKey = preferences.getString("api_key", "");
WiFiManager wm;
// Define custom parameters
WiFiManagerParameter mqtt_field("mqtt", "MQTT Server", savedMqtt.c_str(), 64);
WiFiManagerParameter apikey_field("apikey", "API Key", savedApiKey.c_str(), 40);
wm.addParameter(&mqtt_field);
wm.addParameter(&apikey_field);
// Callback to save custom parameters after portal submission
wm.setSaveParamsCallback([&]() {
preferences.putString("mqtt_server", mqtt_field.getValue());
preferences.putString("api_key", apikey_field.getValue());
Serial.println("Custom parameters saved.");
});
bool connected = wm.autoConnect("ESP32-Setup");
if (!connected) {
ESP.restart();
}
// Use the saved values
String mqttServer = preferences.getString("mqtt_server", "");
String apiKey = preferences.getString("api_key", "");
Serial.printf("MQTT: %s | API Key: %s\n", mqttServer.c_str(), apiKey.c_str());
}
void loop() {}
The portal now shows two extra fields below the WiFi password. Values persist across reboots in NVS.
Custom Portal Styling
The default WiFiManager portal is functional but plain. You can inject custom CSS to match your brand:
WiFiManager wm;
// Inject custom CSS into the portal head
wm.setCustomHeadElement(
"<style>"
"body { font-family: 'Segoe UI', sans-serif; background: #0F1A2E; color: #f1f5f9; }"
"input { background: #1a2744; border: 1px solid rgba(255,255,255,0.08); "
" color: #f1f5f9; border-radius: 8px; padding: 10px; }"
"button { background: #FF6B35; color: white; border: none; "
" border-radius: 8px; padding: 12px 24px; font-weight: 600; cursor: pointer; }"
"button:hover { background: #e55a2b; }"
".c { text-align: center; }"
"</style>"
);
// Optional: set a custom title shown in the portal
wm.setTitle("Device Setup");
Portal Timeout
In production, you do not want the portal to run forever. Set a timeout so the device falls back to AP mode or restarts if nobody configures it:
WiFiManager wm;
// Close the portal after 180 seconds (3 minutes) if no one connects
wm.setConfigPortalTimeout(180);
bool connected = wm.autoConnect("ESP32-Setup");
if (!connected) {
Serial.println("Portal timed out. Running in offline mode...");
// Continue with limited functionality, or restart
}
Reset Button: Force the Portal Open
Users need a way to reconfigure WiFi — maybe they changed routers. Wire a button to a GPIO pin and clear the saved credentials:
#include <WiFiManager.h>
#define RESET_PIN 0 // BOOT button on most ESP32 dev boards
void setup() {
Serial.begin(115200);
pinMode(RESET_PIN, INPUT_PULLUP);
WiFiManager wm;
// If the reset button is held during boot, erase credentials
if (digitalRead(RESET_PIN) == LOW) {
Serial.println("Reset button pressed. Clearing WiFi credentials...");
wm.resetSettings();
delay(1000);
}
bool connected = wm.autoConnect("ESP32-Setup");
if (!connected) {
ESP.restart();
}
Serial.print("Connected. IP: ");
Serial.println(WiFi.localIP());
}
void loop() {}
Hold the BOOT button (GPIO 0) while powering on, and the portal appears fresh, regardless of previously saved credentials.
How Credentials Persist: NVS and Preferences
The ESP32 has a dedicated flash partition called NVS (Non-Volatile Storage). Unlike EEPROM emulation on older Arduino boards, NVS is a proper key-value store with wear levelling.
WiFiManager uses the ESP32's built-in WiFi credential storage (managed by the IDF WiFi stack), which itself sits in NVS. When you call WiFi.begin(ssid, password) and the connection succeeds, the IDF saves those credentials automatically. On the next boot, WiFi.begin() with no arguments will try the last saved credentials.
For your own custom data, the Preferences library provides a clean API:
#include <Preferences.h>
Preferences prefs;
void save() {
prefs.begin("myapp", false); // namespace "myapp", read-write
prefs.putString("mqtt", "192.168.1.100");
prefs.putInt("interval", 30);
prefs.putBool("debug", true);
prefs.end();
}
void load() {
prefs.begin("myapp", true); // read-only
String mqtt = prefs.getString("mqtt", "default.mqtt.server");
int interval = prefs.getInt("interval", 60);
bool debug = prefs.getBool("debug", false);
prefs.end();
}
Key limits: namespace max 15 characters, key max 15 characters. NVS partition is typically 20 KB, more than enough for configuration data.
Approach 2: Building Your Own Captive Portal from Scratch
WiFiManager is convenient, but sometimes you need full control — a completely custom UI, specific validation logic, or integration with a larger web interface already running on the device. Here is how to build the entire captive portal yourself.
Step 1: ESP32 as Access Point with DNS Server
#include <WiFi.h>
#include <DNSServer.h>
#include <WebServer.h>
#include <Preferences.h>
const char* AP_SSID = "ESP32-Setup";
const char* AP_PASS = "configure123";
DNSServer dnsServer;
WebServer server(80);
Preferences preferences;
bool portalActive = false;
void startCaptivePortal() {
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASS);
// Redirect ALL DNS requests to the ESP32's IP
dnsServer.start(53, "*", WiFi.softAPIP());
server.on("/", handleRoot);
server.on("/save", HTTP_POST, handleSave);
server.on("/scan", handleScan);
server.onNotFound(handleRoot); // All unknown URLs go to the portal
server.begin();
portalActive = true;
Serial.printf("Portal active at http://%s\n", WiFi.softAPIP().toString().c_str());
}
The DNS wildcard is the key trick. When a phone connects to the AP and tries to reach connectivitycheck.gstatic.com (Android) or captive.apple.com (iOS), the DNS server resolves it to 192.168.4.1, which triggers the captive portal popup.
Step 2: Serving the HTML Configuration Page
void handleRoot() {
String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WiFi Setup</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0F1A2E; color: #f1f5f9; padding: 20px;
display: flex; justify-content: center; min-height: 100vh;
}
.card {
background: #1a2744; border-radius: 16px; padding: 32px;
max-width: 400px; width: 100%; margin-top: 40px; height: fit-content;
border: 1px solid rgba(255,255,255,0.08);
}
h1 { font-size: 1.5rem; margin-bottom: 8px; }
p.sub { color: rgba(255,255,255,0.48); font-size: 0.875rem; margin-bottom: 24px; }
label { display: block; font-size: 0.75rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 6px; color: rgba(255,255,255,0.7); }
input {
width: 100%; padding: 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.08);
background: #0F1A2E; color: #f1f5f9; font-size: 1rem; margin-bottom: 16px;
}
input:focus { outline: none; border-color: #FF6B35; }
button {
width: 100%; padding: 14px; border-radius: 8px; border: none;
background: #FF6B35; color: white; font-size: 1rem;
font-weight: 600; cursor: pointer; margin-top: 8px;
}
button:hover { background: #e55a2b; }
.networks { margin-bottom: 16px; }
.net-item {
padding: 10px 12px; border-radius: 8px; cursor: pointer;
border: 1px solid rgba(255,255,255,0.08); margin-bottom: 6px;
display: flex; justify-content: space-between; align-items: center;
}
.net-item:hover { background: rgba(255,255,255,0.05); }
.signal { font-size: 0.75rem; color: rgba(255,255,255,0.48); }
</style>
</head>
<body>
<div class="card">
<h1>WiFi Setup</h1>
<p class="sub">Select your network and enter the password.</p>
<div id="networks" class="networks">Scanning...</div>
<form action="/save" method="POST">
<label>Network Name (SSID)</label>
<input type="text" name="ssid" id="ssid" required>
<label>Password</label>
<input type="password" name="password" id="password">
<button type="submit">Connect</button>
</form>
</div>
<script>
fetch('/scan').then(r => r.json()).then(nets => {
const el = document.getElementById('networks');
if (nets.length === 0) { el.innerHTML = 'No networks found.'; return; }
el.innerHTML = nets.map(n =>
`<div class="net-item" onclick="document.getElementById('ssid').value='${n.ssid}'">
<span>${n.ssid}</span>
<span class="signal">${n.rssi} dBm</span>
</div>`
).join('');
});
</script>
</body>
</html>
)rawliteral";
server.send(200, "text/html", html);
}
Step 3: Scanning for Available Networks
void handleScan() {
int n = WiFi.scanNetworks();
String json = "[";
for (int i = 0; i < n; i++) {
if (i > 0) json += ",";
json += "{\"ssid\":\"" + WiFi.SSID(i) + "\",\"rssi\":" + String(WiFi.RSSI(i)) + "}";
}
json += "]";
server.send(200, "application/json", json);
}
Step 4: Saving Credentials and Restarting
void handleSave() {
String ssid = server.arg("ssid");
String password = server.arg("password");
if (ssid.length() == 0 || ssid.length() > 32) {
server.send(400, "text/html", "<h1>Invalid SSID</h1><p><a href='/'>Go back</a></p>");
return;
}
// Save to NVS
preferences.begin("wifi", false);
preferences.putString("ssid", ssid);
preferences.putString("password", password);
preferences.end();
String response = R"rawliteral(
<!DOCTYPE html><html><head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: sans-serif; background: #0F1A2E; color: #f1f5f9;
display: flex; justify-content: center; align-items: center;
min-height: 100vh; text-align: center; }
.ok { color: #10B981; font-size: 1.25rem; }
</style>
</head><body>
<div>
<p class="ok">Credentials saved.</p>
<p>The device will restart and connect to your network.</p>
</div>
</body></html>
)rawliteral";
server.send(200, "text/html", response);
delay(2000);
ESP.restart();
}
Step 5: Tying It All Together
void setup() {
Serial.begin(115200);
preferences.begin("wifi", true);
String savedSSID = preferences.getString("ssid", "");
String savedPass = preferences.getString("password", "");
preferences.end();
if (savedSSID.length() > 0) {
Serial.printf("Connecting to %s...\n", savedSSID.c_str());
WiFi.mode(WIFI_STA);
WiFi.begin(savedSSID.c_str(), savedPass.c_str());
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) {
delay(500);
Serial.print(".");
}
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("\nConnected! IP: %s\n", WiFi.localIP().toString().c_str());
return; // Normal operation starts in loop()
}
Serial.println("\nConnection failed. Starting portal...");
}
startCaptivePortal();
}
void loop() {
if (portalActive) {
dnsServer.processNextRequest();
server.handleClient();
}
// Your application code here (only runs when connected)
}
This gives you a complete, self-contained captive portal with zero external dependencies.
mDNS: Access Your Device by Name
Once the ESP32 is on the network, finding its IP address is inconvenient. mDNS lets you access it as http://mydevice.local from any computer on the same network:
#include <ESPmDNS.h>
void setup() {
// ... after WiFi is connected ...
if (MDNS.begin("mydevice")) {
Serial.println("mDNS started: http://mydevice.local");
MDNS.addService("http", "tcp", 80);
}
}
Platform support: mDNS works out of the box on macOS, iOS, and Linux. On Windows 10+, it works in browsers but not always with ping. Android support is inconsistent -- you may need a Bonjour browser app.
Combine mDNS with a captive portal by showing the .local address on the confirmation page after WiFi is configured.
Security Considerations
A captive portal opens a temporary attack surface. Here is how to minimize risk:
Protect the AP with WPA2. Never leave the setup AP open:
WiFi.softAP("ESP32-Setup", "configure123"); // Always set a password
Print the AP password on a sticker on the device enclosure or in the product documentation.
Validate all user input. The SSID and password come from an HTTP form. Sanitize them:
// Reject SSIDs that are empty or too long
if (ssid.length() == 0 || ssid.length() > 32) {
server.send(400, "text/plain", "Invalid SSID");
return;
}
// WPA2 passwords must be 8-63 characters (or empty for open networks)
if (password.length() > 0 && (password.length() < 8 || password.length() > 63)) {
server.send(400, "text/plain", "Password must be 8-63 characters");
return;
}
HTTPS on the portal: the ESP32 can serve HTTPS using a self-signed certificate, but every browser will show a security warning, which defeats the purpose. For a captive portal that exists for 30 seconds, HTTP is the pragmatic choice. The AP password protects the transport layer.
Disable the portal after configuration. Do not leave the web server running after WiFi is connected unless you explicitly need a management interface. This reduces the attack surface.
Credentials in NVS are stored in plain text by default. The ESP32 supports NVS encryption using a separate key partition. For commercial products handling sensitive environments, enable this.
Common Issues and Fixes
Portal Not Appearing on Phone
Android checks for internet access by hitting connectivitycheck.gstatic.com. If the DNS redirect works, Android shows a "Sign in to network" notification. If it does not:
- Make sure the
DNSServeris running and resolving*to the AP IP. - Make sure
server.onNotFound()is set to serve the portal page -- Android fetches/generate_204, not/. - Some Android versions require the response to return HTTP 200 (not 302) with HTML content.
iOS fetches captive.apple.com/hotspot-detect.html and expects a specific response. Returning your portal HTML on any URL usually triggers the popup.
DNS Not Redirecting
Check that WiFi.mode() is set to WIFI_AP or WIFI_AP_STA, not WIFI_STA. The DNS server only works when the AP interface is active.
Credentials Not Persisting
If you are using the custom approach, make sure you call preferences.end() after writing. Without it, data may not be flushed to flash. Also confirm the NVS partition exists in your partition table (it does by default on all standard ESP32 Arduino configurations).
ESP32 Reboots in a Loop
This usually happens when the saved SSID exists but connection fails repeatedly, and your code calls ESP.restart() without a fallback. Always include a retry counter or fallback to AP mode:
preferences.begin("wifi", false);
int failCount = preferences.getInt("fail_count", 0);
if (failCount >= 3) {
Serial.println("Too many failures. Clearing credentials.");
preferences.remove("ssid");
preferences.remove("password");
preferences.putInt("fail_count", 0);
preferences.end();
startCaptivePortal();
return;
}
// ... attempt connection ...
if (WiFi.status() != WL_CONNECTED) {
preferences.putInt("fail_count", failCount + 1);
preferences.end();
ESP.restart();
}
// Connected successfully — reset counter
preferences.putInt("fail_count", 0);
preferences.end();
Production Deployment: WiFi Manager + OTA Updates
A device that users can configure over WiFi should also be updatable over WiFi. Combine the captive portal with ArduinoOTA for a complete production setup:
#include <WiFiManager.h>
#include <ArduinoOTA.h>
#include <ESPmDNS.h>
void setup() {
Serial.begin(115200);
WiFiManager wm;
wm.setConfigPortalTimeout(180);
// Hold BOOT button to reset WiFi
pinMode(0, INPUT_PULLUP);
if (digitalRead(0) == LOW) {
wm.resetSettings();
}
bool connected = wm.autoConnect("ESP32-Setup", "configure123");
if (!connected) {
ESP.restart();
}
// mDNS for friendly access
MDNS.begin("mydevice");
MDNS.addService("http", "tcp", 80);
// OTA updates
ArduinoOTA.setHostname("mydevice");
ArduinoOTA.setPassword("ota-secret-123");
ArduinoOTA.onStart([]() { Serial.println("OTA update starting..."); });
ArduinoOTA.onEnd([]() { Serial.println("OTA complete. Restarting."); });
ArduinoOTA.onError([](ota_error_t error) {
Serial.printf("OTA Error: %u\n", error);
});
ArduinoOTA.begin();
Serial.printf("Ready. IP: %s | http://mydevice.local\n",
WiFi.localIP().toString().c_str());
}
void loop() {
ArduinoOTA.handle();
// Your application code
}
Now you can push firmware updates from the Arduino IDE over WiFi: Sketch > Upload with the network port selected. No USB cable required after initial deployment.
For web-based OTA (let users upload firmware through a browser), look into the ElegantOTA or AsyncElegantOTA libraries, which provide a drag-and-drop firmware upload page.
Quick Reference
| Feature | Library Approach | Custom Approach |
|---|---|---|
| Setup time | 10 minutes | 1-2 hours |
| Code size | Minimal | ~200 lines |
| Custom UI | CSS injection | Full control |
| Custom parameters | Built-in support | Manual implementation |
| Dependencies | WiFiManager library | None (core ESP32 only) |
| Best for | Most projects | Products needing unique UX |
Wrapping Up
Hardcoded WiFi credentials are the single biggest barrier between a working prototype and a deployable product. A captive portal removes that barrier entirely. Your users power on the device, connect to its setup network, enter their WiFi details in a browser, and they are done. It works on every phone, every tablet, every laptop.
Start with the WiFiManager library if you want to move fast. Switch to a custom implementation when you need full control over the user experience. Either way, your ESP32 projects just became dramatically more portable and user-friendly.
If you are building a project that needs an ESP32 board, sensors, or any IoT components, check out the full range at wavtron.in. We ship across India and stock everything you need to go from breadboard to production.



