A reusable, modular web-configuration server for ESP32 and ESP8266 (Arduino). Provides HTTP digest auth, a dynamic menu, a Status page, an Other page (OTA with firmware-marker validation, restart, factory reset, password change), a Backup page that components register sections on, and a composition API for project-specific pages. Ships an optional, opt-in network-config module that adds no mandatory dependency.
See docs/DESIGN.md for the architecture, the design decisions
behind the boundary, and the pitfalls worth knowing before you extend it, and
docs/CSS.md if you want the pages to look like yours.
Both manifests are shipped, and they name the same libraries differently:
library.json (PlatformIO) takes an owner/name, library.properties (Arduino
Library Manager) takes the library's own name= field. Both are listed, because
installing the wrong fork is the likeliest way to have this not build.
| PlatformIO | Arduino Library Manager | Notes |
|---|---|---|
ESP32Async/AsyncTCP |
Async TCP |
ESP32 only |
ESP32Async/ESPAsyncTCP |
ESP Async TCP |
ESP8266 only |
ESP32Async/ESPAsyncWebServer |
ESP Async WebServer |
the async HTTP server, both platforms |
soosp/HttpDigestAuth |
HttpDigestAuth |
credential storage + digest auth |
Pin the ESP32Async forks. Several incompatible async-TCP libraries sit in the
Library Manager under names a glance cannot tell apart — Async TCP,
ESP Async TCP, ESP AsyncTCP — and the web server declares no depends= of
its own, so nothing installs the right one for you. This library's
library.properties names both TCP layers for that reason; the one your board
does not use is installed but never compiled, since nothing includes its header.
The optional net module additionally needs the NetworkProfile family — but only if you use it (see below).
#include <AsyncConfigPortal.h>
#include <HttpDigestAuth.h>
AsyncConfigPortal web(80);
HttpDigestAuth auth;
void setup() {
// bring up ETH / Wi-Fi first, then:
if (!auth.restore()) { auth.setCredentials("admin", "esp32", "admin"); auth.save(); }
web.setProject("MyProject", "1.0.0", "desc", "2026", "author");
web.setLogger([](AsyncConfigPortal::LogLevel lvl, const char* tag, const char* msg){
// tag names the subsystem ("ota", ...) and can be passed straight to
// ESP_LOGx, which accepts a runtime tag — so the log stays filterable
// per subsystem with esp_log_level_set().
ESP_LOGI(tag, "%s", msg); // map lvl to ESP_LOGE/W/I/D as you like
});
web.begin(auth);
}The pages carry no inline styling: they use the shared class names, so your page
looks like the built-in ones for free. A house style is one call —
setCssExtra(MY_CSS) appends your snippet to the stylesheet every page loads,
the built-in ones included, so ten redefined colour variables restyle the whole
portal; setCss() replaces the sheet outright if you want that far.
docs/CSS.md documents both, and the class contract.
A form can be posted two ways.
- In the page:
onsubmit="return pfSubmit(this, {map: MAP})"(from/common.js) posts the form without leaving, and the handler answers withsendResult(req, code, "ok")or the name of what it refused — with the item's index, a message and a restart flag where they apply. The answer appears in a<span class="pmsg">below the button, a refused field is outlined wheremapnames it, and what the user typed stays. A restart flag covers the page until the device is back, then opens it again (orrestartUrl); aurlin the answer (a new static address) is offered as a link to try. A file upload shows its progress.pfPost(url, body, opts, ctx)does the same for an action that has no form of its own:ctxis the box holding its answer line, andopts.buttonthe button to disable while it runs (an action button inside another form is not a submit button, or Enter in that form would press it). The built-in pages (Network, Syslog, MQTT, Backup, Other) all work this way.sendResult()builds the answer in the buffer the JSON endpoints share; a message longer thanRESULT_MESSAGE_LENgoes with a buffer of the application's own, sized withresultJsonLen(). - As a plain post, answered with the shared result pages
CONFIG_PORTAL_SAVED_HTMLandCONFIG_PORTAL_SAVE_FAILED_HTML: both return to the page the POST came from, so a component serves them as they are and does not name its own path. (A factory reset answers with its own page: the credentials are gone and the address may be, so there is nowhere to go back to.)pfSubmit()understands these answers too, so a handler can be moved over later.
Two things worth knowing when you compose a page:
CONFIG_PORTAL_HEADER/CONFIG_PORTAL_FOOTERare fragments. The footer ends after the project block and does not close</body>or</html>— your page adds those, which is what lets you put your own scripts after it.addResetHandler(what, fn)registers one factory-reset target. Without any handler the button still works: the server erases what it owns (the stored credentials) and restarts. It never silently does nothing — but it also never guesses at your application's storage, so register a handler for each namespace you want cleared.
See examples/Minimal for a complete sketch. Extend the server with your own
pages via addPage(), addJsonEndpoint(), addPostHandler(), addBackupSection()
and addResetHandler(). A form page can build validated fields with the shared
builders served at /fields.js — ifRow (text), numRow (number) and
selectRow (dropdown) — the same ones the built-in pages use. examples/CustomPage
is a complete, hardware-free walkthrough of composing your own page this way.
Digest authentication alone does not stop a page on another site from using the portal: once someone has logged in, their browser adds the stored credentials to any request to the device, including a form that some other page submits in the background (cross-site request forgery). That form could restart the device, change its network settings or upload firmware.
The portal therefore checks every state-changing request (anything but GET,
HEAD and OPTIONS) before its handler runs: the Origin header, or without one
the Referer, must name the host the request was sent to. A page cannot forge
either header, and every current browser sends Origin with a POST. A request
that fails gets 403 and a log line. The check covers the built-in routes, the
components and everything an application adds; uploads are checked chunk by
chunk as well, so a refused firmware upload writes nothing.
portal.setOriginCheck(AsyncConfigPortal::OriginCheck::Strict); // the default| Setting | A request with neither header | Use |
|---|---|---|
Strict |
refused | The default. A script adds the header: curl -H "Origin: http://192.168.1.50" ... |
Lenient |
let through | Old clients or scripts that cannot set a header |
Off |
let through, and no check at all | Debugging only |
A reverse proxy in front of the device must pass the original Host header,
or every request looks cross-site. An upload route a subclass registers
directly on server() gets the middleware check, which runs only after the
upload: such a handler should call originAllowed(req, false) for each chunk
itself.
The Status page's system figures — uptime, reset reason, free heap, its minimum since boot, the largest allocatable block, the fragmentation figure, die temperature, chip, cores, clock and flash size — are read by one public, static call, so a periodic health line or a boot report in your own code shows the same numbers as the page, from the same calls:
AsyncConfigPortal::SystemStats s;
AsyncConfigPortal::systemStats(s);
ESP_LOGD("main", "up=%lu s heap=%lu min=%lu block=%lu reset=%s",
(unsigned long)s.uptimeS, (unsigned long)s.heapFree,
(unsigned long)s.heapMin, (unsigned long)s.heapMaxAlloc, s.resetReason);What a platform cannot provide is flagged (hasHeapMin, hasTemp), not
zeroed — on ESP8266 both are false. No portal instance is needed, and the call
touches no network state.
The same code runs on both platforms; where the hardware differs, so does the page. Nothing here needs configuring — it is what you will see:
-
Status page. ESP8266 shows no Temperature and no Min heap row: it has neither a die-temperature sensor nor minimum-heap tracking. The rows are hidden rather than filled with zeros, because a reading that does not exist is not the same fact as a reading of zero.
-
Wi-Fi TX power (net module). A drop-down of the radio's discrete levels on ESP32; a bounded number input on ESP8266, whose output power is a continuous range.
-
Storage.
Preferencesis part of the core on ESP32, backed by the NVS flash partition. On ESP8266 thevshymanskyy/Preferenceslibrary provides the same API over the internal flash filesystem, storing each entry as a file under/nvs/; LittleFS is the default driver, so the build needs a filesystem in its flash layout. Itsbegin()takes nopartition_label, and keys are limited to 15 characters. -
Read the Status page's Max alloc, not its Fragmentation. The largest block that can be allocated in one piece is what decides whether a page is built; the percentage is
100 x (1 - max alloc / free heap)and only compares with itself under similar load. Freeing memory raises free heap without enlarging the largest block, so the figure rises while the heap improves — a reading of 45% at rest can be healthier than 13% just after a page load. -
TLS on ESP8266 is tight. A BearSSL client reserves about 6 KB the moment it is created and its connection buffers are ~22 KB by default, against roughly 40 KB of heap in total — so a device also serving this portal will not fit both unless all three of these hold: the server agrees to MFLN (
setBufferSizes(512, 512)afterprobeMaxFragmentLength()),HOST_FQDN_LENis lowered (64 is ample in practice, and it shrinks the portal's largest document), and the build uses the basic SSL cipher set — Arduino IDE: Tools > SSL Support > Basic SSL ciphers; PlatformIO:build_flags = -D BEARSSL_SSL_BASIC.Worth knowing because the failure mode is unkind: with TLS up and too little left, pages come up blank, and the setting that caused it can no longer be changed from the portal. An application offering TLS on ESP8266 should measure the largest free block once the connection stands and back out if it is too small —
examples/NetConfigshows the check. ESP32 has none of these limits. -
RAM. ESP8266 has far less of it, and the portal is built to fit: pages stream straight out of flash and large JSON documents are streamed from their buffer rather than copied. Raising
NET_CONFIG_MAX_PROFILESorNTP_PROFILE_SERVER_COUNT(1 to 3) grows the largest buffer, so raise them only as far as the device actually needs. -
-DMQTT_CONFIG_TLS=0removes TLS from the MQTT page: no row, no mention of 8883 in the port hint, and a submittedtlsparameter ignored. A stored flag is left alone, not cleared.The point is not the page. On ESP8266, constructing a
WiFiClientSecurecallsstack_thunk_add_ref(), which reserves BearSSL's call stack from the heap at construction rather than at connect — so a global secure client costs about 6.8 kB from boot whether or not a secured connection is ever opened, and it lands mid-heap, halving the largest free block. Measured on one firmware by removing a client it never used: boot heap 25.0 kB to 31.8 kB, largest block at MQTT connect 11.8 kB to 24.8 kB.It is a macro rather than an
attach()parameter so that the page and the application cannot disagree: the same name decides whether the row appears and whether the client is compiled in at all.
Four ready-made pages ship with the library. Each is header-only and opt-in: including it is what pulls in the library it needs, and a build that never includes it has that dependency at all.
| Include | Page | Pulls in |
|---|---|---|
<NetConfigComponent.h> |
Network — addressing, DNS, hostname, Wi-Fi, and the NTP servers | NetworkProfile (NtpProfile for the Time group, which needs NTP_PROFILE_ENABLED) |
<MqttConfigComponent.h> |
MQTT — broker, port, TLS, credentials, the base topic | NetworkProfile (MqttProfile, 0.10.0+) |
<SyslogConfigComponent.h> |
Syslog — server, port, severity floor, facility | NetworkProfile (SyslogProfile, 0.9.0+) |
<NetStatusComponent.h> |
Net status — the live link, read-only | NetworkManager |
They share the same shape, so knowing one is knowing all four:
component.attach(web, order, label /*, component-specific */); // returns boolattach() returns false if a registration failed — the page registry is finite,
and a component that could not register would otherwise look attached.
Three registries have a fixed size, each overridable before including the header. They are small on purpose: the arrays are members of the server object, so an unused slot is RAM spent on a page nobody registered.
| Macro | Default | Bounds |
|---|---|---|
CONFIG_PORTAL_MAX_PAGES |
16 | every built-in page, module page and project page takes one |
CONFIG_PORTAL_MAX_BACKUP_SECTIONS |
4 | addBackupSection() |
CONFIG_PORTAL_MAX_RESET_HANDLERS |
4 | addResetHandler() |
Each add*() returns false rather than overwriting when its registry is full,
so a build that outgrows one says so instead of losing a page quietly.
Both edit a stored profile and take an optional Backup section of their own:
netConfig.addProfile(ethProfile, "eth"); // one or more
// The NTP servers are edited in a Time group on the same page, from a
// device-level NtpProfile — one clock, one set of servers, rather than a copy in
// every interface. Not calling this leaves the group off the page.
//
// One profile, which is the ordinary case. NetworkProfile also allows one per
// interface (NetworkAdapter::setNtpProfile), for a wired LAN with an internal
// time server a fallback cannot reach; this page does not edit those.
netConfig.setNtpProfile(ntpProfile, "ntp");
netConfig.onSaved([](const NetConfigComponent::NetChangeSet& c) {
if (c.any()) restartPending = true; // restarted from loop(): the answer goes out first
});
netConfig.setRestartOnSave(true); // says so to the Backup page
netConfig.attach(web, AsyncConfigPortal::MENU_NET, "Network", /*withBackup=*/true);
mqtt.setProfile(mqttProfile, "mqtt"); // exactly one
mqtt.setDefaultBaseTopic(defaultBase); // "sensors/<hostname>": the placeholder
mqtt.onSaved([](const MqttConfigComponent::Changed& c) {
if (c.connection) restartPending = true; // the client is built from these
});
mqtt.setRestartOnSave(true);
mqtt.attach(web, AsyncConfigPortal::MENU_NET - 1, "MQTT", /*withBackup=*/true);
syslogPage.setProfile(syslogProfile, "syslog");
syslogPage.onSaved([](const SyslogConfigComponent::Changed& c) {
applySyslog(); // re-read the profile into the sender, in place
});
syslogPage.attach(web, AsyncConfigPortal::MENU_NET - 2, "Syslog", /*withBackup=*/true);None of them owns a client or a connection: they save, and report what changed. What to do about it is the application's call, since the client belongs to it — a restart for an MQTT client that was built from the settings, an in-place re-read for a syslog sender, which holds no connection.
A restart is best done from loop(), a moment after the callback, so that the
answer to the save gets out first. setRestartOnSave(true) tells the Network
or MQTT component that the application restarts: a restore that changes
something then covers the Backup page with "The device is restarting" until the
device answers again, and reloads it. The component cannot know this by itself;
without the call the page only says "Restored".
A Network page is only useful if the device can be reached in the first place,
which is not a given on a wired network with no DHCP server. examples/EthConfigButton
covers the two situations that occur: an unconfigured device — new, or factory
reset — settles on a fixed address when no DHCP answers, and a deployed one stays
on whatever it was given until somebody presses a button, which moves it to that
same address for the length of the visit.
Neither address is ever saved, so a reset always returns to the stored
configuration, and the only way into the configured state is saving on this page.
The example gives the page its own Ethernet profile (savedProfile), loaded from
the same namespace, and puts the fixed address only into the adapter's
(runtimeProfile): the page edits and saves whole profiles, so sharing one would
show the fixed address as the stored configuration and could save it.
Read-only, and open by default — checking a signal level is the sort of thing one
does standing next to the device. Pass AuthLevel::Required where the SSID,
addressing and BSSID should not be readable without a login.
It shows what NetworkManager knows. Anything else is the application's state, so the page takes sections rather than learning about each service:
netStatus.addSection("MQTT", [](char* buf, size_t len) {
if (!enabled) return false; // section omitted entirely
return snprintf(buf, len, "{\"Broker\":\"%s\",\"State\":\"%s\"}",
host, mqtt.connected() ? "connected" : "offline") < (int)len;
});
netStatus.attach(web);NetConfigComponent ships in the library as a ready, opt-in managed
network-config page. It is header-only and pulls in the NetworkProfile family
only when you include it:
#include <NetConfigComponent.h> // opt inand add the NetworkProfile library to your lib_deps. If you never include it,
there is no NetworkProfile dependency. If you want your own page instead, write
it against the composition API — that is what these three are built on.
⚠️ Two constraints keep this dependency-free — see DESIGN.md:
- Keep
NetConfigComponentheader-only.- Use
lib_ldf_mode = chain(the PlatformIO default).deep/deep+scan all headers and would force-resolveNetworkProfile.heven when unused.
The library's tunables are macros read by library sources, not by your sketch.
Under PlatformIO put them in build_flags — one mechanism, both platforms:
build_flags =
-DNET_CONFIG_MAX_PROFILES=2
-DNTP_PROFILE_SERVER_COUNT=3Under the Arduino IDE a #define in the sketch will not reach them:
library sources are separate translation units. Each core has its own file for
this, and they are not interchangeable:
| Core | File in the sketch folder | Contents |
|---|---|---|
| ESP32 | build_opt.h |
compiler flags, one per line, no comments |
| ESP8266 | <sketch>.ino.globals.h |
a /* @create-file:build.opt@ ... */ block holding the flags |
// build_opt.h (ESP32)
-DNET_CONFIG_MAX_PROFILES=2
-DNTP_PROFILE_SERVER_COUNT=3
-DMQTT_CONFIG_TLS=0The ESP32 examples ship a build_opt.h; the accompanying sketch documents what
each flag is for. The ESP8266 counterpart is not shipped, because the IDE
hides an example folder that contains a .ino.globals.h — write one yourself
when building an example for ESP8266.
Because the two cores read different files, this is also how you give the same sketch different flags per platform under the IDE: each core picks up its own file and ignores the other's.
⚠️ After the first build, editing the file has no effect on its own. The Arduino IDE does not notice the change and reuses the previous build objects and cached core. Force a full rebuild: close the IDE, or change any board menu option (the upload method, say) — either invalidates the cache.
Either way the IDE reads the file only from the sketch folder.
The OTA path validates an embedded firmware identity so a wrong image is rejected. Set these per build (defaults exist for a first run):
build_flags =
-DFIRMWARE_PROJECT='"my-project"' ; <= 23 chars
-DFIRMWARE_BOARD='"my-board"' ; <= 23 chars
-DFIRMWARE_VERSION=100 ; major * 10000 + minor * 100 + patchReference the embedded marker once in your app (e.g. log g_fwMarker) so the
linker keeps it.
The ESP32 bootloader can undo an update that does not run. With
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE (set in the Arduino core's
sdkconfig) a newly written image is started once, marked pending; if the
device resets before that image confirms itself — a crash in setup(), a
watchdog, a brownout — the next boot goes back to the firmware in the
other OTA slot. Nothing is counted and nothing is timed by the bootloader:
one unconfirmed run, then back.
The Arduino core would confirm every pending image before setup() runs,
which makes the mechanism useless; the portal defers that
(verifyRollbackLater(), defined in the library) and confirms instead
60 seconds after boot, from a timer: the device has run that long
without a reset, which is what an update can be judged by at this level.
Whether the network came up or the sensors answer is the device's
environment, not the firmware's doing, and no reason to go back. The delay
is setFirmwareConfirmDelay(seconds); 0 confirms in begin().
An application can confirm by itself instead
(setFirmwareConfirmBy(AsyncConfigPortal::FirmwareConfirm::Application)),
which ties the confirmation to its main loop running: the same delay,
checked from loop():
void loop() {
portal.confirmFirmwareWhenDue(); // once the uptime reaches the delay
...
}confirmFirmwareWhenDue() does its work once and costs a comparison after
that. An application that takes this on must make the call: an image that
is never confirmed is rolled back at the next reset, however long it ran —
a setup() that waits for something that never comes would do that at the
next power cycle. The delay is a minimum uptime, not a deadline: a long
setup() is uptime too. confirmFirmware() confirms at once, for an
application with a condition of its own.
A deliberate action confirms at once: an authenticated request that
changes state — a save, a restore, Restart, Factory reset, a firmware
upload, any POST of the application's — confirms a pending image before
its handler runs (Boot previous excepted); an upload does so at its first
chunk, before any of its data is used. (Without that, an update uploaded
in the first minute after another would fail: the bootloader's OTA API
refuses to write while the running image is unconfirmed.) The firmware
has started and serves its user, which is what the delay waits for; a
restart the user asked for is not a crash, and a save may write a record
the previous firmware cannot read. An application that restarts by itself
(a command over MQTT, a button) calls confirmFirmware() first, for the
same reason.
So, after an update, a reset in the first minute that nobody asked for
— a power cut, a crash, a watchdog — boots the previous firmware. The log
says which: updated firmware, unconfirmed: confirmed in 60 s unless the device resets, then firmware confirmed (or firmware confirmed by a request (/restart)); after a rollback the next boot logs the last update did not confirm and was rolled back, once. firmwarePending() and
firmwareRolledBack() report the state for a status line;
firmwareJustRolledBack() is true on that first boot after a rollback
only, once per rolled-back image (an image uploaded again counts anew),
for the application to tell someone (an SMS, an MQTT message) without
remembering anything itself:
portal.begin(auth);
if (portal.firmwareJustRolledBack()) notify("firmware update rolled back");The Other page shows the state (Running: an update, not confirmed yet / confirmed; Other slot: the firmware before the last update / the update that was rolled back / empty) and has a Boot previous button: it boots the other slot's image, which then starts as an unconfirmed update itself — a bad one is rolled back again within the minute. The running image is not marked invalid, so the two can be switched as often as needed until the next update overwrites the other slot.
The ESP8266 has no rollback; there the state reads as confirmed, the form
is hidden and confirmFirmware() is a no-op. The same holds on an ESP32
core whose sdkconfig lacks CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE (the
bootloader is the core's, prebuilt, so an application cannot turn it on);
the build then says so with a #warning. The official Arduino core 3.x
has it on.