Software Engineer at Supabase
August 2, 2025
(iOS / macOS friendly — no external services required)
This guide shows how to write an ESP32 sketch that
boots in Access-Point (AP) mode,
pops up a captive-portal page on any connected device,
lets the user pick an SSID & enter its password,
stores the credentials in flash memory, then
reboots into Station (STA) mode and connects automatically.
The trick is to scan for networks only before the AP starts – scanning afterwards kills the Soft-AP briefly, causing the notorious “Hotspot login could not open the page” error on Apple devices.
Calling WiFi.scanNetworks()
while the Soft-AP is running disables the radio for a short time. All phones lose the Wi-Fi network exactly when the browser is about to send the POST request with the credentials – so the request never arrives.
Fix:
enable station mode temporarily*,
scan once*, cache the SSID list,
switch to pure AP mode* before serving pages.
Toolchain:* PlatformIO or Arduino IDE
Board:* any ESP32-Dev (WROOM / WROVER)
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <Preferences.h> // simple NVS wrapper
WebServer server(80);
DNSServer dnsServer;
Preferences prefs;
const char* AP_SSID = "ESP32_1";
// cache up to 8 SSIDs found during the pre-scan
String networks[8];
int networkCount = 0;
bool shouldConnectToWiFi = false;
String selectedSSID, enteredPassword;
void setupAP() {
Serial.println("[LOG] Starting AP mode…");
// 1️⃣ enable Station interface **temporarily** so we can scan
WiFi.mode(WIFI_AP_STA);
Serial.println("[LOG] Scanning for networks…");
int n = WiFi.scanNetworks();
networkCount = min(n, 8);
for (int i = 0; i < networkCount; ++i) {
networks[i] = WiFi.SSID(i);
Serial.println(" • " + networks[i]);
}
// 2️⃣ switch back to pure AP so clients know there is no Internet
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID);
Serial.println("AP IP: " + WiFi.softAPIP().toString());
// Wildcard DNS every hostname ⇒ ESP32
dnsServer.start(53, "*", WiFi.softAPIP());
// Web routes
server.on("/", showMainPage);
server.on("/connect", HTTP_POST, handleConnect);
// Captive-portal probes (Android, iOS, Windows…)
server.on("/generate_204", [](){ redirectRoot(); });
server.on("/hotspot-detect.html",[](){ redirectRoot(); });
server.on("/ncsi.txt", [](){ server.send(200,"text/plain","Microsoft NCSI"); });
server.on("/connecttest.txt", [](){ server.send(200,"text/plain",""); });
server.on("/fwlink", [](){ redirectRoot(); });
server.onNotFound([](){ showMainPage(); });
server.begin();
}
void redirectRoot() {
server.send(200,"text/html",
"<html><body><script>location.href='/'</script></body></html>");
}
void showMainPage() {
String html = R"rawliteral(
<!DOCTYPE html><html><head>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<style>
body{font-family:sans-serif;padding:1em;max-width:600px;margin:0 auto}
ul{list-style:none;padding:0} li{margin:.5em 0}
a{display:block;padding:1em;background:#007bff;border-radius:8px;
text-decoration:none;color:#fff;font-size:1.2em;text-align:center}
a:hover{background:#0056b3}
.form{display:none;margin-top:1em;border:1px solid #ddd;padding:1em;border-radius:8px}
input,button{width:100%;padding:1em;font-size:1.1em;margin-top:1em}
button{background:#28a745;border:none;color:#fff;border-radius:4px}
</style></head><body>
<h2>Select WiFi Network</h2><ul>)rawliteral";
for (int i = 0; i < min(networkCount,3); ++i) {
String ssidEsc = networks[i]; ssidEsc.replace("\"",""");
html += "<li><a href='#' onclick=\"sel('"+ssidEsc+"')\">"
+ ssidEsc + "</a></li>";
}
if (networkCount==0) html += "<li>No networks found</li>";
html += R"rawliteral(</ul>
<div id=form class=form>
<h2 id=title>Enter Password</h2>
<form method='POST' action='/connect'>
<input type=hidden name=ssid id=ssid>
<input type=password name=password placeholder='Password' autofocus>
<button>Connect</button>
</form>
</div>
<script>
function sel(s){ssid.value=s;title.textContent='Enter password for '+s;
document.getElementById('form').style.display='block'}
</script></body></html>)rawliteral";
server.send(200,"text/html",html);
}
void handleConnect() {
if (!server.hasArg("ssid") || !server.hasArg("password")) {
server.send(400,"text/plain","Missing fields"); return;
}
selectedSSID = server.arg("ssid");
enteredPassword = server.arg("password");
// Store credentials in flash (NVS)
prefs.begin("wifi", false);
prefs.putString("ssid", selectedSSID);
prefs.putString("pass", enteredPassword);
prefs.end();
shouldConnectToWiFi = true; // loop() will handle the change
server.send(200,"text/html",
"<h2>Connecting to "+selectedSSID+"…</h2><p>You can close this.</p>");
}
void connectToStoredWiFi() {
prefs.begin("wifi", true);
String ssid = prefs.getString("ssid","");
String pass = prefs.getString("pass","");
prefs.end();
if (ssid=="") return; // nothing saved yet
WiFi.mode(WIFI_STA);
WiFi.begin(ssid.c_str(), pass.c_str());
unsigned long t0 = millis();
while (WiFi.status()!=WL_CONNECTED && millis()-t0<10000) {
delay(500); Serial.print(".");
}
Serial.println(WiFi.status()==WL_CONNECTED ?
"\n[LOG] Connected!" : "\n[ERR] Failed, back to AP");
if (WiFi.status()!=WL_CONNECTED) setupAP();
}
void loop() {
if (WiFi.getMode()==WIFI_AP) {
dnsServer.processNextRequest();
server.handleClient();
delay(10); // watchdog friendly
}
if (shouldConnectToWiFi) {
shouldConnectToWiFi = false;
connectToStoredWiFi();
}
}
void setup() {
Serial.begin(115200); delay(1000);
Serial.println("[LOG] Boot – heap: " + String(ESP.getFreeHeap()));
connectToStoredWiFi(); // try saved credentials
if (WiFi.status()!=WL_CONNECTED)
setupAP(); // else start captive-portal
}
Flash the firmware & open the Serial Monitor.
Join ESP32_1 on your phone/laptop – the portal pops up.
Pick a network → enter password → Connect.
Serial shows Saving credentials …
, ESP32 reboots into STA → joins the chosen Wi-Fi.
On next boot it connects automatically; if it fails it falls back to AP mode.
Scan once* in WIFI_AP_STA
, cache SSIDs, then switch to WIFI_AP
.
Never call WiFi.scanNetworks()
while the AP is running.
Single-page HTML + wildcard DNS gives a fast, reliable provisioning flow that works on iOS, Android, macOS & Windows.
Happy building! 🚀