Captive Portal & WiFi Provisioning¶
Not flashed to hardware
Reasoned through against the documented ESP8266 ESP8266WiFi /
DNSServer / ESP8266WebServer APIs and the ESP32 WiFi /
DNSServer / WebServer APIs. Not compiled or flashed to physical
hardware in this environment.
The provisioning problem¶
Hard-coding a WiFi SSID and password in source works for one bench device, not for something you'll hand to someone else. A captive portal lets a device with no known network start its own access point, serve a small web form for credentials, then reboot and join the real network — the same pattern smart plugs and cameras use out of the box.
Step 1: start a SoftAP when no known network exists¶
// softap-fallback.ino
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#else
#include <WiFi.h>
#endif
const char* AP_SSID = "NodeMCU-Setup";
void startProvisioningAP() {
WiFi.mode(WIFI_AP);
// softAP() with no password argument creates an open AP; documented
// on both cores. Use a password string for a protected setup AP.
WiFi.softAP(AP_SSID);
Serial.print("AP started, IP: ");
Serial.println(WiFi.softAPIP()); // documented to return 192.168.4.1 by default
}
Step 2: redirect every DNS query to the portal (the "captive" part)¶
Phones/laptops detect a captive portal by trying to resolve a connectivity-check domain; answering every query with the AP's own IP makes the OS pop the portal automatically.
// dns-redirect.ino
#include <DNSServer.h>
DNSServer dnsServer;
const byte DNS_PORT = 53;
void startCaptiveDns() {
IPAddress apIP = WiFi.softAPIP();
// start(port, domain, resolvedIP): "*" documented as wildcard,
// matches all queried domains on both cores' DNSServer library.
dnsServer.start(DNS_PORT, "*", apIP);
}
void loop() {
dnsServer.processNextRequest(); // must be polled, not interrupt-driven
}
Step 3: serve the credentials form¶
// captive-portal.ino
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
ESP8266WebServer server(80);
#else
#include <WiFi.h>
#include <WebServer.h>
WebServer server(80);
#endif
#include <DNSServer.h>
#include <LittleFS.h>
DNSServer dnsServer;
const char* AP_SSID = "NodeMCU-Setup";
const char* FORM_HTML =
"<html><body><h3>WiFi Setup</h3>"
"<form method='POST' action='/save'>"
"SSID: <input name='ssid'><br>"
"Password: <input name='pass' type='password'><br>"
"<input type='submit' value='Save & Reboot'>"
"</form></body></html>";
void handleRoot() {
server.send(200, "text/html", FORM_HTML);
}
void handleSave() {
String ssid = server.arg("ssid");
String pass = server.arg("pass");
File f = LittleFS.open("/wifi.cfg", "w");
if (f) {
f.println(ssid);
f.println(pass);
f.close();
}
server.send(200, "text/html", "Saved. Rebooting...");
delay(1000);
ESP.restart(); // documented on both cores
}
void setup() {
Serial.begin(115200);
LittleFS.begin(true);
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID);
dnsServer.start(53, "*", WiFi.softAPIP());
server.on("/", handleRoot);
server.on("/save", HTTP_POST, handleSave);
// Serve the form for any unmatched path too, since captive-portal
// detection probes hit arbitrary URLs.
server.onNotFound(handleRoot);
server.begin();
}
void loop() {
dnsServer.processNextRequest();
server.handleClient();
}
ESP8266WebServer/WebServer are documented to expose the same
on()/onNotFound()/handleClient() surface on both cores, which is
why the sketch only needs to branch on the include and class name.
Step 4: try the saved credentials on boot, fall back to the portal¶
// boot-with-fallback.ino
bool tryStoredWifi() {
File f = LittleFS.open("/wifi.cfg", "r");
if (!f) return false;
String ssid = f.readStringUntil('\n');
String pass = f.readStringUntil('\n');
f.close();
ssid.trim();
pass.trim();
if (ssid.length() == 0) return false;
WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), pass.c_str());
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) {
delay(250);
}
return WiFi.status() == WL_CONNECTED;
}
void setup() {
Serial.begin(115200);
LittleFS.begin(true);
if (!tryStoredWifi()) {
startProvisioningAP(); // from step 1
// ... plus DNS/web-server setup from steps 2-3
} else {
Serial.println("Connected: " + WiFi.localIP().toString());
}
}
How It Actually Works¶
Putting the ESP into SoftAP mode reconfigures its single radio to act as the access point rather than a station — it starts beaconing its own SSID (transmitting Beacon frames roughly every 100ms carrying its capability info) and runs a minimal AP-side association state machine, handling incoming Probe/Auth/Association Requests from phones and laptops using the same 802.11 primitives a commercial router uses, just implemented in the SDK's much smaller AP stack. Once a device associates, the ESP's built-in DHCP server (a tiny embedded DORA responder) hands out an IP from its own private subnet, and DNS requests are answered by a DNS "hijack" — the captive portal library runs a minimal DNS server that resolves every hostname query to the ESP's own AP IP address regardless of what was actually asked, which is the specific mechanism that makes a phone's "Sign in to network" prompt pop up automatically: the OS's own connectivity-check probe (a hardcoded URL like connectivitycheck.gstatic.com) resolves to the ESP, gets an unexpected response, and the OS concludes it must be behind a captive portal needing a browser.
Serving the actual provisioning form works over that same association without full internet routing — the ESP's tiny HTTP server answers the browser's request locally, and submitted Wi-Fi credentials are typically written straight to flash (NVS/EEPROM) before the sketch switches the radio back to station mode and attempts association with the newly provided network — the practical failure mode people hit is that a device already connected to the SoftAP with cached "no internet, stay connected anyway" state from the OS won't re-trigger the captive portal popup on a second attempt, because that decision is cached client-side, not something the ESP controls.
(These examples were written and reasoned through at the register/protocol level but were not flashed to a physical board for this pass — verify timing-sensitive details against your exact chip datasheet before relying on them in production.)
Exercise¶
- Explain why answering every DNS query with the AP's own IP (rather than only known captive-portal check domains) is what triggers most phones/laptops to auto-open the portal UI.
- Extend
handleSave()to validate the SSID field is non-empty before writing to LittleFS, returning a 400 response otherwise (server.sendsupports an arbitrary status code, per the documented API). - Add a "forget WiFi" mode: holding a button (GPIO input) during boot
should call
LittleFS.remove("/wifi.cfg")and start the provisioning AP even if stored credentials exist. - Storing WiFi passwords in a plaintext LittleFS file is common but has a real limitation — name it, and describe one documented mitigation available on the ESP32 (hint: flash encryption / NVS encryption) that the ESP8266 does not offer.