Jordi Enric

Software Engineer at Supabase logoSupabase

Back

August 2, 2025

ESP32 Captive Portal Wi-Fi Provisioning

(iOS / macOS friendly — no external services required)

This guide shows how to write an ESP32 sketch that

  1. boots in Access-Point (AP) mode,

  2. pops up a captive-portal page on any connected device,

  3. lets the user pick an SSID & enter its password,

  4. stores the credentials in flash memory, then

  5. 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.


1. Why captive portals usually break on ESP32

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.


2. Project setup

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

3. Global variables

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;

4. One-time Wi-Fi scan & AP start-up

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>");

}

5. Captive-portal HTML (single page)

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("\"","&quot;");

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);

}

6. Saving credentials & triggering STA connection

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>");

}

7. Switching to Station mode

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();

}

8. Main loop

void loop() {

if (WiFi.getMode()==WIFI_AP) {

dnsServer.processNextRequest();

server.handleClient();

delay(10); // watchdog friendly

}

if (shouldConnectToWiFi) {

shouldConnectToWiFi = false;

connectToStoredWiFi();

}

}

9. Boot sequence

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

}

10. Try it out

  1. Flash the firmware & open the Serial Monitor.

  2. Join ESP32_1 on your phone/laptop – the portal pops up.

  3. Pick a network → enter password → Connect.

  4. Serial shows Saving credentials …, ESP32 reboots into STA → joins the chosen Wi-Fi.

  5. On next boot it connects automatically; if it fails it falls back to AP mode.


Recap

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! 🚀

Back to all posts