The Direct Answer: Building a Captive Portal on the ESP8266
To build a functional captive portal on an ESP8266, you must pair the DNSServer library with the ESP8266WebServer library. The DNS server intercepts all domain requests and resolves them to your ESP's local IP (192.168.4.1), while the web server catches the resulting HTTP traffic. However, simply redirecting all traffic is no longer enough. Modern mobile operating systems (iOS, Android, Windows) use specific background probe URLs to detect captive networks. If your ESP8266 does not explicitly handle these probe URLs with an HTTP 302 Found redirect, the device will display a 'No Internet' warning but fail to trigger the captive portal pop-up.
The code and architecture in this guide specifically target the Wemos D1 Mini (ESP8266) and the NodeMCU v3 (ESP8266MOD). Both share the same underlying ESP8266 silicon and GPIO mapping for this application, making the firmware universally deployable across these two standard maker boards.
Decision Tree: Which Captive Portal Approach to Choose
Before wiring anything up, you need to decide which architectural pattern fits your project. Do not default to the most complex option if a simpler library solves your actual problem.
| Approach | Best For | Complexity | Verdict / Concrete Pick |
|---|---|---|---|
| Raw DNS + WebServer (This Guide) | Custom UI, IoT device provisioning, offline kiosks, learning HTTP/DNS mechanics. | Medium | CHOOSE THIS if you need total control over the HTML/CSS and want to avoid third-party library bloat. |
| WiFiManager Library | Harvesting WiFi credentials to connect the ESP8266 to a local router. | Low | Choose this if your only goal is getting the ESP online. It includes a pre-built captive portal for SSID/Password entry. |
| ESPAsyncWebServer | High-traffic portals, serving large files from LittleFS, handling simultaneous WebSocket connections. | High | Choose this only if you are serving heavy assets or need non-blocking concurrent HTTP requests. |
Parts List and Pin Mapping
This build requires minimal hardware. The ESP8266 handles the RF and processing internally. We are using the onboard LED for status indication, which requires understanding the active-low logic of the ESP8266 GPIO2 pin.
Bill of Materials (BOM)
- Microcontroller: Wemos D1 Mini (ESP8266) OR NodeMCU v3 (ESP8266MOD) - Ensure it has the CP2104 or CH340 USB-to-Serial chip for easy flashing.
- Power: USB Micro-A cable (data + power) or a 5V/1A USB wall adapter.
- Antenna: Onboard PCB antenna is sufficient for captive portals (range ~15 meters line-of-sight). No external SMA antenna required unless mounting in a metal enclosure.
Pin Mapping Table
| Function | Wemos D1 Mini Pin | NodeMCU v3 Pin | ESP8266 GPIO | Notes |
|---|---|---|---|---|
| Status LED | D4 | D4 | GPIO 2 | Active LOW (LOW = ON, HIGH = OFF). Must be HIGH at boot. |
| 5V Input | 5V | VIN / 5V | N/A | Regulated down to 3.3V onboard. |
| Ground | G | GND | N/A | Common ground for any external peripherals. |
Complete Compilable Code (Target: Wemos D1 Mini / NodeMCU)
This code implements the critical OS-specific probe URL handlers. Without these, Apple and Android devices will silently fail to open your portal. The code includes pin definitions, watchdog-safe looping, and explicit HTTP 302 redirects.
#include <ESP8266WiFi.h>
#include <DNSServer.h>
#include <ESP8266WebServer.h>
// --- PIN DEFINITIONS ---
const int STATUS_LED = 2; // GPIO2 (D4 on Wemos/NodeMCU) - Active LOW
// --- NETWORK CONFIG ---
const byte DNS_PORT = 53;
const char* AP_SSID = "ESP8266_Portal";
const char* AP_PASS = "portal1234"; // Min 8 chars for WPA2
IPAddress apIP(192, 168, 4, 1);
IPAddress subnetMask(255, 255, 255, 0);
DNSServer dnsServer;
ESP8266WebServer webServer(80);
// --- HTML PAYLOAD ---
const char* PORTAL_HTML = R"rawliteral(
<!DOCTYPE html><html><head>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<title>Device Setup</title></head>
<body style='font-family:sans-serif;text-align:center;padding:20px;'>
<h2>ESP8266 Captive Portal</h2>
<p>You are connected to the setup network.</p>
<form action='/save' method='POST'>
<input type='text' name='user' placeholder='Username'><br><br>
<input type='password' name='pass' placeholder='Password'><br><br>
<button type='submit'>Connect</button>
</form></body></html>
)rawliteral";
void handleRoot() {
webServer.send(200, "text/html", PORTAL_HTML);
}
void handleSave() {
String user = webServer.arg("user");
String pass = webServer.arg("pass");
// In a real app, save to EEPROM/LittleFS here
webServer.send(200, "text/html", "<h2>Saved! Rebooting...</h2>");
delay(2000);
ESP.restart();
}
// Catch-all redirect to force the portal
void handleNotFound() {
webServer.sendHeader("Location", "http://192.168.4.1/", true);
webServer.send(302, "text/plain", "");
}
// --- CRITICAL: OS CAPTIVE PORTAL DETECTION HANDLERS ---
void setupOSProbes() {
// Apple iOS / macOS
webServer.on("/hotspot-detect.html", HTTP_GET, []() {
webServer.sendHeader("Location", "http://192.168.4.1/", true);
webServer.send(302, "text/plain", "");
});
webServer.on("/library/test/success.html", HTTP_GET, []() {
webServer.sendHeader("Location", "http://192.168.4.1/", true);
webServer.send(302, "text/plain", "");
});
// Android / ChromeOS
webServer.on("/generate_204", HTTP_GET, []() {
webServer.sendHeader("Location", "http://192.168.4.1/", true);
webServer.send(302, "text/plain", "");
});
webServer.on("/gen_204", HTTP_GET, []() {
webServer.sendHeader("Location", "http://192.168.4.1/", true);
webServer.send(302, "text/plain", "");
});
// Windows
webServer.on("/connecttest.txt", HTTP_GET, []() {
webServer.sendHeader("Location", "http://192.168.4.1/", true);
webServer.send(302, "text/plain", "");
});
webServer.on("/redirect", HTTP_GET, []() {
webServer.sendHeader("Location", "http://192.168.4.1/", true);
webServer.send(302, "text/plain", "");
});
}
void setup() {
pinMode(STATUS_LED, OUTPUT);
digitalWrite(STATUS_LED, LOW); // Turn ON LED during boot
Serial.begin(115200);
Serial.println("\n[BOOT] Starting Captive Portal...");
// Configure Access Point
WiFi.mode(WIFI_AP);
WiFi.softAPConfig(apIP, apIP, subnetMask);
if (!WiFi.softAP(AP_SSID, AP_PASS)) {
Serial.println("[ERROR] SoftAP config failed!");
while(1) { delay(1000); }
}
Serial.print("[OK] AP IP address: ");
Serial.println(WiFi.softAPIP());
// Start DNS Server (Captive)
dnsServer.start(DNS_PORT, "*", apIP);
// Setup Web Routes
setupOSProbes();
webServer.on("/", HTTP_GET, handleRoot);
webServer.on("/save", HTTP_POST, handleSave);
webServer.onNotFound(handleNotFound);
webServer.begin();
Serial.println("[OK] HTTP server started.");
digitalWrite(STATUS_LED, HIGH); // Turn OFF LED (Ready)
}
void loop() {
dnsServer.processNextRequest();
webServer.handleClient();
yield(); // Feed the software watchdog timer
}
Debugging: First Three Things to Check When It Fails
When working with ESP8266 RF and network stacks, failures usually manifest in three specific ways. Here is your ranked troubleshooting path.
1. The Serial Monitor Shows: Soft WDT reset
The Symptom: The ESP8266 boots, prints the IP address, and then randomly restarts every 3 to 7 seconds, printing Soft WDT reset or rst cause:4, boot mode:(3,7) to the serial console.
The Cause: The ESP8266 runs a background RTOS that manages the WiFi stack. If your loop() function or a web server handler blocks execution for more than 3.2 seconds (e.g., using a long delay(), a blocking while() loop waiting for a sensor, or heavy synchronous file I/O), the software Watchdog Timer (WDT) assumes the chip has locked up and forces a reset.
The Fix: Never use delay() inside your web server handlers. If you must wait for a hardware peripheral, use yield() or ESP.wdtFeed() inside your waiting loop to manually feed the watchdog. Notice the yield(); at the very bottom of the loop() function in the code above—this is mandatory for network stability.
2. Phone Connects, Shows 'No Internet', But No Pop-Up Appears
The Symptom: Your phone connects to the 'ESP8266_Portal' WiFi. The OS warns you that the network has no internet access. However, the Captive Network Assistant (the web view pop-up) never launches.
The Cause: Modern mobile OS background-probe known servers. If the ESP8266 returns a standard 200 OK with HTML when the OS requests /generate_204 (Android) or /hotspot-detect.html (iOS), the OS interprets this as a broken router rather than a captive portal. Furthermore, if you previously connected to this ESP and the OS cached the DNS response, it won't re-trigger the portal.
The Fix: Ensure the setupOSProbes() function from the code block is included and active. You must return an HTTP 302 Found redirect pointing to your root IP. To clear the OS cache during testing, toggle Airplane mode on your phone, forget the ESP8266 WiFi network, and reconnect.
3. Compiler Error: fatal error: ESP8266WiFi.h: No such file or directory
The Symptom: The Arduino IDE fails to compile, throwing a missing file error for core ESP libraries.
The Cause: You have a standard Arduino AVR board (like the Uno or Nano) selected in the IDE's board manager, or the ESP8266 core JSON URL is missing from your preferences.
The Fix: Go to File > Preferences and paste http://arduino.esp8266.com/stable/package_esp8266com_index.json into the 'Additional Board Manager URLs' field. Then, open the Boards Manager, search for 'esp8266', and install the latest core by ESP8266 Community. Finally, select LOLIN(WEMOS) D1 R2 & mini or NodeMCU 1.0 from the Tools > Board menu.
ESP8266WebServerSecure class, though this will increase memory overhead significantly.
Extending and Simplifying the Build
Once you have the baseline captive portal functioning, you will likely want to scale the UI or simplify the deployment. Here is how to adapt the architecture based on your end goal.
Extending: Moving HTML to LittleFS
Storing HTML in const char* string literals consumes precious RAM and bloats the compiled binary. For complex portals with CSS and JavaScript, format the ESP8266's SPIFFS or LittleFS partition.
Use the ESP8266 SoftAP documentation to map your flash memory, and serve files directly using LittleFS.open("/index.html", "r"). This frees up the heap, preventing the Exception (28): LoadProhibited crashes that occur when the ESP runs out of memory while handling concurrent DNS and HTTP requests.
Simplifying: The WiFiManager Shortcut
If your sole objective is to allow a user to input their home WiFi SSID and Password so the ESP8266 can connect to the internet, stop writing custom DNS handlers. Use the WiFiManager library. It wraps the DNS interception, OS probe handling, and UI generation into a single wifiManager.autoConnect("AP_NAME") call. It is the industry standard for ESP8266/ESP32 credential provisioning and handles the edge cases of iOS 16+ and Android 14 captive portal timeouts automatically.
By understanding the underlying HTTP 302 mechanics and the OS-specific probe URLs, you transition from copying broken tutorials to engineering reliable embedded network interfaces. Test your portal across at least three different OS environments (iOS, Android, Windows) before finalizing your firmware, as each vendor's Captive Network Assistant enforces slightly different timeout and redirect rules.






