You unbox a small IoT device, plug it in, and… nothing.
No screen. No keyboard. No buttons that make any sense.
This is a familiar situation if you've ever worked with microcontrollers like the ESP32. The hardware is powerful, but the very first interaction is often awkward. At some point, the device needs to connect to your Wi-Fi network, and the classic question appears:
"How does a tiny device ask for Wi-Fi credentials?"
Hardcoding credentials works for quick experiments, but it breaks down in the real world, every deployment would require re-flashing the firmware. Serial consoles are fine for developers, not for users who don't have USB cables or the patience for terminal commands. Mobile apps add friction and maintenance overhead, requiring separate iOS and Android versions that need constant updates.
What you want instead is simple: power on the device, connect to it once from any phone or laptop, configure it through a familiar web interface, and never think about it again. No apps to install, no cables to find, no special tools required.
This is exactly where captive portals shine, they turn Wi-Fi provisioning into a one-time, friction-free experience that works on every platform.
What Is a Captive Portal?
If you've ever connected to Wi-Fi at a coffee shop, hotel, or airport, you've used a captive portal.
You join the network, and a web page automatically pops up asking you to log in or accept terms.
You didn't type a URL—the network guided you there by intercepting your browser traffic.
A captive portal on an embedded device works the same way:
- The device creates its own Wi-Fi network (acting as an access point)
- The user connects from their phone or laptop (no password needed)
- The device intercepts all internet requests using a DNS server
- A web page automatically opens showing the configuration form
- After submission, the device saves credentials and switches to normal Wi-Fi client mode
The beauty of this approach is that the ESP32 can handle everything on-device.
It acts as both an access point and a web server simultaneously, with no cloud services, external infrastructure, or internet connection required during setup. It's significantly simpler than building a companion mobile app (which requires maintaining iOS and Android versions) and far more user-friendly than hardcoded credentials or serial console configuration.
For IoT products, this is often the difference between "technically works" and "actually ships to customers."
Architecture Overview
The device goes through two distinct phases: setup mode (captive portal) and normal operation (Wi-Fi client).
Setup Mode runs the first time you power on the device, or anytime no Wi-Fi configuration exists.
In this mode, the ESP32:
- Creates its own temporary Wi-Fi network
- Runs a DNS server that redirects all domains to itself
- Runs an HTTP server that serves the configuration page
- Saves user-submitted credentials to flash storage
Normal Operation begins after successful configuration.
In this mode, the ESP32:
- Disables the access point
- Connects to the user's Wi-Fi network as a client
- Runs your actual application (fetching data, controlling hardware, etc.)
The key decision happens at boot: does config.json exist? If yes, connect to Wi-Fi. If no, start the captive portal.
If you have already MicroPython installed on your ESP32, you are good to go and experiment with the following examples.
If not, see this guide for more detail on how to setup an ESP32 with MicroPython.
Step 1 - Create the Wi-Fi Access Point
The ESP32 creates its own Wi-Fi network using the built-in network module. This is the foundation of the entire captive portal, without the access point, users have no way to reach the configuration page.
The ESP32 has two Wi-Fi interfaces:
-
AP_IF(Access Point Interface) - Makes the ESP32 act like a router -
STA_IF(Station Interface) - Makes the ESP32 act like a Wi-Fi client
During setup mode, we use AP_IF to create a temporary network that users can join from their phones.
In your local project, create a new file wifi_ap.py:
import network
def start_access_point():
ap = network.WLAN(network.AP_IF)
ap.active(True)
ap.config(
essid="MyDevice-Setup",
authmode=network.AUTH_OPEN
)
print("Access point active:", ap.active())
print("AP IP address:", ap.ifconfig()[0])
return ap
Once this runs, the ESP32 broadcasts a Wi-Fi network named "MyDevice-Setup" and assigns itself IP address 192.168.4.1. Any device that connects to this network can communicate with the ESP32 using this IP.
Why use an open network?
You'll notice we use AUTH_OPEN (no password). This might seem insecure, but it's actually the right choice for a temporary setup network:
- Better compatibility: Some devices have issues with WPA2 on captive portals
- Faster setup: Users don't need to type a password
- Auto-detection works better: Operating systems are more likely to show the "Sign in to network" popup on open networks
- Temporary by design: The AP only exists during initial setup and disappears once configured
The security risk is minimal because:
- The network only exists during the 1-2 minutes of setup
- The user is typically standing right next to the device
- Once configured, the AP shuts down completely
For production devices, you can add strategies like auto-disabling the AP after 10 minutes, or requiring a physical button press to re-enter setup mode.
Step 2 - Build the HTML Setup Page
Before we create the HTTP server, let's build the setup page that users will see. This is the critical user-facing component, it needs to work flawlessly on every mobile device.
Design Principles for Captive Portal Pages:
- Mobile-first: Most users will access this from their phones
- Minimal dependencies: No external CSS/JS frameworks (we don't have internet!)
- Fast loading: Every byte travels over a slow access point connection
- Clear purpose: Users should immediately understand what to do
- Forgiving inputs: Don't assume users type perfectly
The page needs only two inputs: SSID and password. Everything else can be configured later through a different interface (if needed at all).
On your local project folder, create portal.html:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Device Setup</title>
<style>
body {
font-family: sans-serif;
padding: 16px;
max-width: 400px;
margin: 0 auto;
}
h2 {
margin-top: 0;
}
label {
display: block;
margin-top: 12px;
font-weight: bold;
}
input, button {
width: 100%;
padding: 10px;
margin-top: 6px;
font-size: 16px;
box-sizing: border-box;
}
button {
margin-top: 20px;
background: #007bff;
color: white;
border: none;
cursor: pointer;
}
button:hover {
background: #0056b3;
}
</style>
</head>
<body>
<h2>Wi-Fi Setup</h2>
<form method="POST" action="/save">
<label>
Wi-Fi SSID
<input name="ssid" required>
</label>
<label>
Password
<input name="password" type="password">
</label>
<button type="submit">Save & Connect</button>
</form>
</body>
</html>
Design notes:
-
viewportmeta tag ensures proper scaling on mobile devices - Inline CSS keeps everything in one file (no extra HTTP requests)
-
box-sizing: border-boxprevents input overflow issues - Large touch targets (10px padding) work better on phones
- Simple color scheme (#007bff) looks professional without complexity
Step 3 - Create HTTP Server with Form Handling
Now create an HTTP server that serves the HTML file and handles form submissions.
This is more complex than a typical web server because it needs to:
-
Serve the same page for any URL - Operating systems make requests to various paths like
/generate_204,/hotspot-detect.html, etc. to detect captive portals - Handle form submissions - Parse POST data and save credentials
- Provide error feedback - Show clear messages when things go wrong
- Be resilient - The ESP32's networking stack is fragile, so we need careful error handling
The server handles both GET requests (show the form) and POST requests (save configuration).
On your local project folder, create http_server.py:
import socket
import json
def parse_form_data(body):
"""Parse URL-encoded form data"""
data = {}
pairs = body.split("&")
for pair in pairs:
if "=" in pair:
key, value = pair.split("=", 1)
# URL decode
data[key] = value.replace("+", " ").replace("%40", "@")
return data
def save_config(data):
"""Save Wi-Fi credentials to config.json"""
if not data.get("ssid"):
raise ValueError("SSID is required")
config = {
"ssid": data.get("ssid"),
"password": data.get("password", ""),
}
with open("config.json", "w") as f:
json.dump(config, f)
print("Configuration saved:", config)
def start_http_server():
addr = socket.getaddrinfo("0.0.0.0", 80)[0][-1]
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(1)
print("HTTP server listening on port 80")
while True:
try:
conn, addr = s.accept()
request = conn.recv(2048).decode()
if request.startswith("POST /save"):
# Handle form submission
body = request.split("\r\n\r\n", 1)[1]
form_data = parse_form_data(body)
save_config(form_data)
response_body = """<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Setup Complete</title>
<style>
body { font-family: sans-serif; padding: 16px; text-align: center; }
h2 { color: #28a745; }
</style>
</head>
<body>
<h2>Configuration saved</h2>
<p>Device will reboot and connect to Wi-Fi.</p>
<p><small>You can close this window.</small></p>
</body>
</html>"""
else:
# Serve the setup page for any other request
try:
with open("portal.html") as f:
response_body = f.read()
except:
response_body = "<h1>Setup Page Not Found</h1><p>Please upload portal.html</p>"
response = (
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html\r\n"
"Connection: close\r\n\r\n"
+ response_body
)
conn.send(response.encode())
except Exception as e:
print("HTTP error:", e)
try:
error = f"<h2>Error</h2><p>{e}</p>"
conn.send(
"HTTP/1.1 500 Internal Server Error\r\n"
"Content-Type: text/html\r\n"
"Connection: close\r\n\r\n"
.encode() + error.encode()
)
except:
pass
finally:
try:
conn.close()
except:
pass
Key implementation details:
Form parsing: The parse_form_data() function handles URL-encoded data (the format HTML forms use). We manually decode common characters like + (space) and %40 (@) since MicroPython doesn't have a built-in URL decoder.
File handling: The server reads portal.html from the filesystem. If the file is missing, it shows an error message instead of crashing, this helps with debugging during development.
Connection management: Connection: close is critical. MicroPython's socket implementation can leak memory with persistent connections. We explicitly close every connection, even in error cases, to prevent the ESP32 from running out of sockets.
Error handling: The triple-nested try/except blocks might look excessive, but they prevent the server from crashing when:
- A client disconnects mid-request
- The request is malformed
- The filesystem is corrupted
- Memory runs low
Response encoding: Notice we call .encode() when sending responses. MicroPython's socket.send() expects bytes, not strings.
Step 4 - Add DNS Redirection (The "Captive" Part)
To trigger the automatic "Sign in to network" popup on phones, we need a DNS server that redirects all domains to the ESP32's IP. This is the "captive" part of "captive portal", we're capturing all DNS requests.
How DNS redirection works:
When your phone connects to a new Wi-Fi network, it performs a "captive portal check" by trying to reach a known URL (like http://captive.apple.com on iOS or http://connectivitycheck.gstatic.com on Android).
Here's what happens:
- Phone asks DNS: "What's the IP address of captive.apple.com?"
- Our DNS server lies: "It's 192.168.4.1" (the ESP32's IP)
- Phone tries to load that page
- Our HTTP server responds with the setup page
- Phone realizes: "This isn't the real captive.apple.com, must be a captive portal!"
- Phone shows the "Sign in to network" popup
The key insight: we don't need to know which domain was requested. We just answer everything with our own IP address.
Create dns_server.py:
import socket
AP_IP = "192.168.4.1"
def ip_to_bytes(ip):
return bytes([int(x) for x in ip.split('.')])
def start_dns_server():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((AP_IP, 53))
print("DNS server listening on", AP_IP)
while True:
try:
data, addr = sock.recvfrom(512)
if len(data) < 12:
continue
tid = data[:2]
flags = b"\x81\x80"
qdcount = b"\x00\x01"
ancount = b"\x00\x01"
nscount = b"\x00\x00"
arcount = b"\x00\x00"
dns_header = tid + flags + qdcount + ancount + nscount + arcount
# Parse the question section
idx = 12
while idx < len(data) and data[idx] != 0:
idx += 1
idx += 5
question = data[12:idx]
# Respond only to A-record queries
qtype = data[idx-4:idx-2]
if qtype != b"\x00\x01":
sock.sendto(dns_header + question, addr)
continue
# Build answer pointing to AP_IP
answer = (
b"\xc0\x0c"
b"\x00\x01"
b"\x00\x01"
b"\x00\x00\x00\x3c"
b"\x00\x04"
+ ip_to_bytes(AP_IP)
)
response = dns_header + question + answer
sock.sendto(response, addr)
except Exception as e:
print("DNS error:", e)
How the DNS packet parsing works:
DNS packets have a specific structure. We extract:
-
Transaction ID (
tid): Unique identifier for each query, must be echoed back -
Flags:
0x8180means "this is a response, no error" - Question section: The domain name the client asked about
- Answer section: Our fabricated response pointing to 192.168.4.1
We only respond to A-record queries (IPv4 address lookups). Other query types (AAAA for IPv6, MX for mail servers, etc.) are safely ignored.
Platform compatibility notes:
iOS is particularly strict about captive portal detection. The popup appears more reliably if:
- DNS replies are fast (our implementation is)
- HTTP responses are well-formed (we use proper HTML)
- The server is already running before the phone connects
If the popup doesn't appear automatically, users can manually open any browser and type any URL (like example.com). The DNS redirection will still work, bringing them to your setup page.
Important: Captive Portal Auto-Detection Limitations
The automatic captive portal popup is not guaranteed to appear. Whether it shows up depends on:
- Operating system: iOS is most reliable, Android varies by manufacturer (Samsung/Xiaomi less reliable), Windows 10/11 is hit-or-miss
- Timing: The popup only appears if DNS/HTTP checks happen within a few seconds of connecting
- Network history: If the phone previously connected to this SSID, it may skip checks
- Phone settings: Some phones have "captive portal detection" disabled in developer options
- Carrier settings: Some mobile carriers modify captive portal behavior
In real-world testing, automatic popups appear on approximately 70-80% of devices. The other 20-30% require users to manually open a browser.
Step 5 - Connect to User's Wi-Fi
After saving configuration, the device needs to switch from AP mode to STA mode (Wi-Fi client).
This is the transition from "setup" to "normal operation."
The connection logic needs to be robust because Wi-Fi connections fail more often than you'd expect:
- Wrong password (most common)
- Weak signal strength
- Router configured to block new devices
- Network congestion
- 5GHz vs 2.4GHz band issues (ESP32 only supports 2.4GHz)
Our implementation tries for 20 seconds (20 attempts × 1 second), which is long enough for most networks but not so long that users think the device is frozen.
Create wifi_client.py:
import network
import time
import json
def connect_to_wifi():
"""Try to connect to saved Wi-Fi network"""
try:
with open("config.json") as f:
config = json.load(f)
except:
print("No config file found")
return False
ssid = config.get("ssid")
password = config.get("password", "")
if not ssid:
print("No SSID in config")
return False
# Disable AP mode
ap = network.WLAN(network.AP_IF)
ap.active(False)
# Enable STA mode
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.connect(ssid, password)
print(f"Connecting to {ssid}...")
for _ in range(20):
if sta.isconnected():
print("Connected!")
print("IP:", sta.ifconfig()[0])
return True
time.sleep(1)
print("Connection failed")
return False
Connection flow breakdown:
-
Check for config: Try to load
config.json. If it doesn't exist, returnFalseimmediately - Disable AP: Turn off the access point interface (can't be AP and STA simultaneously effectively)
- Enable STA: Activate the station (client) interface
- Connect: Attempt to connect using saved credentials
- Wait and retry: Check connection status every second for up to 20 seconds
-
Report result: Return
Trueif connected,Falseif timeout
Why the 20-second timeout?
- Most successful connections happen in 2-5 seconds
- Some networks with complex auth take 10-15 seconds
- Anything longer probably indicates a real problem (wrong password, etc.)
- 20 seconds is long enough to succeed but short enough that users don't think the device is frozen
Putting It All Together
Now let's create a complete main.py that integrates all the components. This is the entry point that decides: "Should I start the captive portal, or connect to Wi-Fi?"
The logic is simple:
- Try to connect to saved Wi-Fi
- If successful → run the main application
- If failed → start captive portal for (re)configuration
This creates a self-healing system: if the user changes their Wi-Fi password or moves the device to a new network, it automatically falls back to setup mode.
import network
import time
import _thread
import machine
# Import our modules
from wifi_ap import start_access_point
from http_server import start_http_server
from dns_server import start_dns_server
from wifi_client import connect_to_wifi
def start_captive_portal():
"""Start the captive portal in AP mode"""
print("\n=== Starting Captive Portal ===")
# Create access point
ap = start_access_point()
time.sleep(1) # Give AP time to start
# Start DNS server in background thread
_thread.start_new_thread(start_dns_server, ())
time.sleep(0.5)
# Start HTTP server (blocks here)
print("Captive portal ready!")
print("Connect to 'MyDevice-Setup' from your phone\n")
start_http_server()
def main():
print("\n" + "="*40)
print("ESP32 Captive Portal Starting...")
print("="*40 + "\n")
# Try to connect to saved Wi-Fi network
if connect_to_wifi():
print("\n=== Connected to Wi-Fi ===")
print("Starting main application...\n")
# Your main application code here
try:
import urequests
print("Testing internet connection...")
response = urequests.get("https://worldtimeapi.org/api/ip")
if response.status_code == 200:
data = response.json()
print(f"Success! Current time: {data.get('datetime', 'N/A')}")
response.close()
except Exception as e:
print(f"Internet test failed: {e}")
# Your app continues here...
print("\nReady for normal operation!")
else:
# No config or connection failed - start captive portal
print("\n=== Starting Setup Mode ===")
start_captive_portal()
# Run the main function
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n\nShutting down...")
except Exception as e:
print(f"\nFatal error: {e}")
print("Rebooting in 5 seconds...")
time.sleep(5)
machine.reset()
Code walkthrough:
start_captive_portal(): This function orchestrates setup mode:
- Starts the access point
- Launches the DNS server in a background thread (using
_thread) - Starts the HTTP server in the foreground (which blocks indefinitely)
The threading is important: both servers need to run simultaneously. The DNS server runs in the background while the HTTP server handles the main event loop.
main(): This is the decision point:
- Calls
connect_to_wifi()to attempt connection - On success: runs your application code (we show a simple internet connectivity test)
- On failure: starts the captive portal
Error handling: The try/except blocks catch:
-
KeyboardInterrupt: Allows clean shutdown during development - General exceptions: Reboots the device to recover from crashes
File Structure
Your ESP32 should have these files in its root directory:
/
├── main.py # Main entry point
├── wifi_ap.py # Access point setup
├── http_server.py # HTTP server with form handling
├── dns_server.py # DNS server for captive portal
├── wifi_client.py # Wi-Fi connection logic
└── portal.html # Setup page HTML
Why modular files?
Breaking the code into separate modules has several advantages:
- Easier debugging: Test each component independently
- Cleaner code: Each file has one clear purpose
-
Reusability: Copy
dns_server.pyto other projects - Maintainability: Easier to find and fix bugs
All Python files should be in the root directory. MicroPython doesn't handle complex directory structures well, so keep it simple.
Full source code is available at: https://github.com/nunombispo/ESP32-Captive-Portal
Upload Files to ESP32
The easiest way to upload files to your ESP32 is using Thonny IDE, which has built-in MicroPython support.
Install Thonny: Download from thonny.org
Connect ESP32: Plug in your ESP32 via USB
Configure Thonny:
- Go to
Tools→Options→Interpreter - Select "MicroPython (ESP32)"
- Choose your COM port (e.g., COM3 on Windows, /dev/ttyUSB0 on Linux)
- Click OK
Upload Files:
- In Thonny, go to
View→Files - Open your local project folder in the top section
- Right-click on each file and select 'Upload to /' (this will upload the file to the device)
Run:
- Press the
Stop/Restartbutton (or Ctrl+D in the Shell) - Click the green run button
Demo: Real-World Usage
Here's what the complete flow looks like in practice:
Step 1: Device Powers On
The ESP32 boots and starts the captive portal. In the serial monitor (Thonny's Shell), you'll see:
========================================
ESP32 Captive Portal Starting...
========================================
No config file found
=== Starting Setup Mode ===
=== Starting Captive Portal ===
Access point active: True
AP IP address: 192.168.4.1
DNS server listening on 192.168.4.1
Captive portal ready!
Connect to 'MyDevice-Setup' from your phone
HTTP server listening on port 80
Step 2: Connect from Phone
On your phone's Wi-Fi settings, you'll see "MyDevice-Setup" appear as an open network. Tap to connect.
Expected behavior:
- Best case: A popup appears within 3-5 seconds saying "Sign in to network"
- Common case: No popup appears - this is normal! Just open any browser and type any URL
Step 3: Configuration Page
Whether you got the popup or opened a browser manually, you'll see the setup page with two fields:
- Wi-Fi SSID (your network name)
- Password
Enter your actual Wi-Fi credentials and click "Save & Connect"
Step 4: Success Confirmation
You'll see a page saying "Configuration saved" with instructions to reboot the device.
In the Thonny shell, you can see the configuration save log:
HTTP server listening on port 80
Configuration saved: {'ssid': 'SSID_EXAMPLE', 'password': 'password example'}
Step 5: Normal Operation
Reboot the ESP32 and after reboot, the serial monitor shows:
========================================
ESP32 Captive Portal Starting...
========================================
Connecting to SSID_EXAMPLE...
Connected!
IP: 192.168.2.73
=== Connected to Wi-Fi ===
Starting main application...
Testing internet connection...
Success! Current time: 2026-01-23T10:03:12.498011+01:00
Ready for normal operation!
Conclusion
Captive portals solve a fundamental problem: getting a headless device onto a user's network without hardcoded credentials or mobile apps.
For ESP32 projects, it's the difference between a prototype and a product someone else can actually use.
The ESP32 is powerful enough to run an access point, DNS server, and web server simultaneously, making Wi-Fi provisioning a simple, app-free experience.
Follow me on Twitter: https://twitter.com/DevAsService
Follow me on Instagram: https://www.instagram.com/devasservice/
Follow me on TikTok: https://www.tiktok.com/@devasservice
Follow me on YouTube: https://www.youtube.com/@DevAsService


Top comments (0)