Enterprise-Grade Crypto on a $20 Board: Securing Modbus With ESP32-S3
How I put mutual TLS, X.509 certificate validation, and a five-tab LVGL dashboard on top of a 50-year-old Modbus RTU bus, on a $20 ESP32-S3 board with 512 KB of SRAM. Memory map, bisect tables, security architecture, and the case for why low-cost hardware is now a real competitor to $2000 industrial gateways.
- Filed
- Updated
- Reading
- 14 min
- Section
- C / Embedded & IoT
- Tags
Updated 09.10.2026The LVGL pool moved to PSRAM the next day (free internal heap at boot: 22 KB to 110 KB), and memory health now reaches Central.Jump to the update ↓
- The 1979 protocol meets 2026 threat model
- The TLS chain, in detail
- Why this matters under NIS2 and the EU CRA
- The memory map (where the security context lives)
- Why mbedtls cannot use PSRAM
- The mechanisms that bought us each byte
- Where I got burned
- The LVGL pool bisect
- The competitive picture
- The mental model I wish I had on day one
- What the boundary device looks like in production
- Update, October 2026
ESP32-S3 looks generous on the box: 512 KB of internal SRAM, 8 MB of PSRAM, 16 MB of flash, two 240 MHz cores. The whole module costs about $5. The development board with an 800x480 capacitive touch panel costs about $50.
I asked one of these boards to do something the industrial-gateway market currently charges $1500 to $3000 for: terminate Modbus RTU from a solar inverter, run mutual TLS over MQTT to a remote control plane, validate the broker's X.509 certificate chain, host a full LVGL UI with five tabs of live data, and survive 24/7 deployment in someone's garage.
It works. The story of how it got there is half memory engineering, half cryptography on tight rations, and half a quiet observation about how the commercial gateway market is suddenly very vulnerable.
01The 1979 protocol meets 2026 threat model¶
Modbus RTU was published in 1979. It is the standard way industrial equipment talks - solar inverters, PLCs, building automation, every kind of meter. The protocol has no concept of authentication, no encryption, no integrity check beyond a 16-bit CRC, and no notion of who is on the other end of the wire. Anyone with physical access to the RS-485 pair can read every register, and with one bit-flip, write them.
For four decades that was acceptable because Modbus lived on physically isolated factory floors. In 2026 it lives on devices that also have WiFi, MQTT, and a public IP somewhere upstream. The threat model has changed completely; the protocol has not.
The honest answer is to keep Modbus where it belongs - on the short physical wire to the inverter - and wrap everything north of it in modern cryptography. The ESP32 acts as the boundary: Modbus to the south, mutual TLS to the north.
+-------------------------------+
inverter <---->| ESP32-S3 boundary device |<====> MQTT broker
(Modbus RTU) | | (mTLS, X.509)
| Modbus | TLS 1.2 + ECC |
| trust = | trust = client |
| physical| cert + chain |
| wire | validation |
+-------------------------------+That boundary device is doing something that 99% of factory-floor Modbus gateways do not do: it requires both ends of the MQTT session to prove who they are, with X.509 certificates issued by a private CA. Anonymous TLS is not accepted. Plain MQTT is not accepted. The cert chain is validated against a CA bundle baked into the firmware. Every published telemetry message rides over a session that was authenticated at both ends.
This used to require a Cisco or Sierra Wireless gateway box. It now runs on a chip the size of a postage stamp.
02The TLS chain, in detail¶
Here is what the boundary device's MQTT handshake actually does, step by step:
- DNS + TCP connect to the broker. WiFi up, ARP resolved, three-way handshake done.
- TLS 1.2 ClientHello. We send our supported cipher suites: ECDHE-ECDSA-AES128-GCM-SHA256, ECDHE-RSA-AES128-GCM-SHA256, and a couple of fallbacks. Forward-secrecy required. No
NULLorEXPORTciphers compiled in. - Broker sends its certificate chain. Usually three certs: server, intermediate, root. mbedtls walks the chain.
- Client cert validation. mbedtls compares the chain's root against our CA bundle (compiled into the firmware via
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE, which holds the full Mozilla root store, ~250 KB of certs cached in flash). The intermediate is verified against the root; the server cert against the intermediate. Signature checks happen on the ESP32's hardware-accelerated SHA-256 and RSA engines. - Server name verification. The SAN field on the server cert must match the broker hostname. No wildcard shortcuts.
- Client cert sent. We are doing mutual TLS - the broker also wants to know who we are. Our client cert is loaded from secure storage on the ESP32 (encrypted NVS, key sealed at provisioning time).
- ECDHE key exchange. Ephemeral key pair generated, shared secret derived, session keys derived from the master secret + nonces.
- Finished messages. Both sides confirm. Application data starts flowing.
Total handshake time on the ESP32-S3: about 1.2 seconds cold, mostly burned in RSA signature verification. Bandwidth ceiling on the TLS context: about 30 kilobytes of internal SRAM at peak. That last number is the key constraint everything else has to work around.
The CA cert is dropped from RAM the moment the handshake finishes (CONFIG_MBEDTLS_DYNAMIC_FREE_CA_CERT). The TLS record buffers are dynamically allocated and freed between sessions (CONFIG_MBEDTLS_DYNAMIC_BUFFER). Both flags exist precisely because mbedtls' default behavior is to keep ~24 KB of internal SRAM camped on the certs for the lifetime of the connection, and a long-running MQTT session is forever.
03Why this matters under NIS2 and the EU CRA¶
The EU Cyber Resilience Act and NIS2 directive both apply to "products with digital elements" including IoT gateways used in critical infrastructure. Energy is explicitly in scope. The requirements include:
- Authenticated, integrity-protected communications by default.
- Cryptographic identity at the device level (not "shared API key").
- Vulnerability handling and patchability for the device lifetime.
- Documented security architecture.
A cloud-tethered WiFi dongle that uses HTTP and a global API key fails all four. A Cerbo GX gateway, while well-designed, is closed-source and the vulnerability response goes through one vendor.
A boundary device built on ESP32-S3 with the configuration above:
- mTLS with per-device X.509 cert (cryptographic identity, not a shared secret).
- TLS 1.2 only, forward-secret cipher suites only (authenticated + integrity-protected).
- OTA updates with signature verification (patchability).
- Open-source firmware (auditable architecture).
The compliance story writes itself. Hardware identity is rooted in the ESP32's eFuse; signing keys are sealed; secure boot v2 verifies the bootloader and the app at every power-on. The device cannot be downgraded to an older firmware version without the eFuse-rooted signature check failing.
This is the same security architecture you would find on a $2000 industrial gateway. On a $20 module.
04The memory map (where the security context lives)¶
The price you pay for running enterprise-grade TLS on a microcontroller is that every byte matters. Here is what the bytes are doing in production, with the security-relevant regions highlighted:
The CA bundle in PSRAM is fine - it is read-only data accessed at handshake time, not in a critical-section context. The mbedtls TLS state, on the other hand, cannot live in PSRAM. That distinction took me a full day of debugging to find.
05Why mbedtls cannot use PSRAM¶
I tried. The configuration flag exists (CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y), and on the first attempt the TLS handshake completed. The first MQTT publish after the handshake rebooted the chip with a LoadProhibited panic on a PSRAM address.
The root cause: parts of mbedtls' session state cache are touched from inside an interrupt-disabled critical section. PSRAM reads can stall (cache miss into the external bus) and that is not allowed in an ISR-disabled context. The ESP32's cache controller signals a fault, the CPU panics, the watchdog reboots us.
The fix is permanent: mbedtls allocates from internal SRAM only. There is no software workaround on this chip. This is what makes the LVGL-pool-versus-mbedtls bisect so tight - they are fighting over the same 209 KB.
06The mechanisms that bought us each byte¶
XIP_FROM_PSRAM - the free 40 KB¶
CONFIG_SPIRAM_XIP_FROM_PSRAM=yCode and read-only data move to PSRAM, cached through the chip's instruction cache. Cold-cache penalty drops, ~40 KB of internal SRAM (formerly holding the firmware text) is freed. This is the single most useful flag on the chip if you have PSRAM.
SPIRAM_MALLOC_ALWAYSINTERNAL=16384 - the routing rule¶
malloc() allocations smaller than 16 KB stay in internal SRAM; larger ones go to PSRAM automatically. Hot small structs stay fast, bulk buffers go where there is room. Lower the threshold if mbedtls is starving.
MBEDTLS_DYNAMIC_BUFFER + DYNAMIC_FREE_CA_CERT - mbedtls breathing room¶
Default mbedtls keeps TLS buffers and the CA chain alive for the lifetime of the connection. On a long-lived MQTT session that meant ~24 KB of internal SRAM was permanently parked. With these flags, the buffers come and go with each session, and the CA cert is dropped the moment it has done its job (verified the peer). Bought back ~15 KB of steady-state internal SRAM.
lv_label_set_text_static - skipping the copy¶
LVGL's default lv_label_set_text copies the string into a buffer owned by the widget. For a 16 KB Modbus metrics dump refreshed every poll, that meant constant alloc/free churn in the LVGL pool. Over hours the free list fragmented enough that the lazy build of the Settings tab could no longer find a contiguous chunk for a style allocation.
static char s_modbus_buf[16384];
// Once at build time:
lv_label_set_text_static(txt_area, s_modbus_buf);
// At poll time, just write into the static buffer:
format_metrics_into(s_modbus_buf, sizeof(s_modbus_buf), sample);The label's text pointer is a stable .bss address. Zero allocation. Fragmentation stops.
Per-tab refresh gating¶
Hidden tabs do not need their labels rebuilt. Walk up the parent chain at the start of screen_modbus_refresh; bail out if any ancestor has LV_OBJ_FLAG_HIDDEN. Cut per-poll LVGL workload by ~60% on the typical "user is looking at the dashboard" path.
07Where I got burned¶
Crash 1: StoreProhibited at lv_obj_set_style_text_color¶
LVGL's allocator returned NULL during the lazy build of the Settings tab; the next dereference walked off into address 0. Root cause: 64 KB LVGL pool was fragmented enough that no 200-byte contiguous block remained.
Crash 2: ESP_ERR_MBEDTLS_X509_CRT_PARSE_FAILED (0x8015)¶
With LVGL bumped to 96 KB, mbedtls could no longer get its working memory for X.509 parsing. The largest free internal block was 18 KB; cert chain validation needed 24. Hard fail at TLS handshake time. Result: the device could not connect to the broker. Security and memory engineering colliding at the worst possible time - when the device has to authenticate.
Crash 3: LoadProhibited inside mbedtls session cache (PSRAM address)¶
mbedtls routed to PSRAM. Worked for one handshake; rebooted us on the first publish. ISR-disabled critical section touched a PSRAM address; cache miss stalled; chip panicked. Permanent verdict: mbedtls is internal-SRAM-only on this chip.
Crash 4: The backlight that could not be turned off¶
The 4.3-inch Waveshare board wires the LCD backlight through a CH422G I/O expander pin behind a one-shot "enable IO outputs" register whose default pattern is all-low. There is no software path to dynamically toggle the backlight without permanently killing it. Workaround: a full-screen black lv_obj overlay simulates "asleep" while the actual backlight stays on. Cosmetic, not real power saving, but the UX matches user expectations.
08The LVGL pool bisect¶
The LVGL static pool and mbedtls fight over the same internal heap. Too small and LVGL's Settings tab can't allocate its widget styles. Too big and mbedtls runs out of space mid-handshake. The viable window is one bisect point wide:
There is no principled reason 72 KB is the answer. It just is. The seesaw between LVGL widget allocation and mbedtls X.509 parsing has exactly one stable point, and four bisect rounds is what it took to find it.
09The competitive picture¶
Here is what the industrial-gateway market currently looks like for solar / building monitoring:
| Product | Approx. price | Display | mTLS / cert validation | Open source |
|---|---|---|---|---|
| Cisco IR1101 + IoT modem | $2000-$3000 | No | Yes | No |
| Victron Cerbo GX + GX Touch 50 | $700-$1000 | Yes | Limited (HTTP+JSON) | No |
| Sunsynk WiFi data logger | $80 | No | No (cloud bound) | No |
| Fronius Datamanager 2.0 | bundled | No | Limited | No |
| ESP32-S3 board described above | ~$50 | Yes | Yes (per-device cert) | Yes |
Two of those rows do mTLS with per-device certificates. One of them costs forty times what the other does. Neither of them is open source except the ESP32 line.
This is not a niche observation. Every solar inverter installation in Europe will need to comply with NIS2 and the Cyber Resilience Act over the next several years. Every one of them currently relies on cloud-tethered WiFi dongles with no real device identity. The market opportunity for a $50 boundary device that does the security correctly is enormous, and the technical work to build one is now genuinely feasible on hobbyist hardware.
The thing that used to make $2000 gateways defensible was the embedded engineering effort to make mTLS, secure boot, and a real-time UI fit on a microcontroller. ESP32-S3 + esp-idf 5.4 + mbedtls + LVGL closes that gap. The remaining differentiator for the expensive gateways is brand trust, support contracts, and certification paperwork - all of which are work, but none of which are technology moats.
10The mental model I wish I had on day one¶
- Internal SRAM is the rivalrous good. LVGL versus mbedtls is a real fight on every byte. Bisect both. Document the result.
- PSRAM is mostly free, but not for security context. Framebuffers, code, app heap - all fine. TLS state, ISR-touched data, anything that runs in a cache-disabled critical section - keep internal.
- Static buffers win three ways. No allocator fragmentation. Stable across long sessions. Clearer in code review.
- Skip work for invisible widgets. Hidden tabs do not need their labels rebuilt. One
lv_obj_has_flagcheck up the parent chain. - Modbus is fine for the physical-wire trust boundary. It is not fine north of that. Wrap it in mTLS, validate the cert chain, rotate identities, drop the CA cert from RAM after handshake.
11What the boundary device looks like in production¶
After all of this:
- 40 Hz LVGL refresh on the 800x480 panel
- mutual TLS over MQTT, with X.509 certificate chain validation against a private CA, surviving across reboots and network drops
- Modbus RTU polling at 0.5 s inter-block intervals
- 5 fully-built UI tabs, all reachable without an OOM
- ~5.7 MB free PSRAM, ~17 percent internal heap free at steady state
- Secure boot v2 + flash encryption enabled, eFuse-rooted device identity
- OTA updates with signature verification
Total bill of materials: approximately $50.
The boring conclusion: on a constrained MCU there is no single right configuration. Every flag is a trade. But the uninteresting conclusion is that we are now well past the point where serious cryptography, real-time UI, and industrial protocol gateways need expensive proprietary hardware. The constraints are tight, the engineering is non-trivial, and the answer is sometimes a hard bisect between two crashes - but the resulting device does what a $2000 gateway does, for the price of a nice dinner.
The next several years of industrial-IoT compliance pressure are going to make that an interesting place to be.
12Update, October 2026¶
The 72 KB bisect result did not survive the next day, in a good way.
The LVGL pool moved to PSRAM. The fight described above was between LVGL's static pool and mbedtls, both living in internal SRAM. On June 16 I stopped asking which one should lose. With CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY=y and LVGL's large static arrays tagged EXT_RAM_BSS_ATTR through a CMake compile option, the 72 KB pool lives in PSRAM. Free internal heap at boot went from 22 KB to 110 KB, and the largest contiguous internal block from 8 KB to 31 KB. MQTT now connects on the first attempt. The pool is still 72 KB, but that number no longer has to balance anything, so the seesaw chart above describes the old layout.
A retry bug that looked like a memory bug. The same commit fixed an MQTT start path that, after one failed start, kept "succeeding" instantly on a dead client and never connected. The status pill stayed red forever and it looked exactly like memory starvation. Any failed start now tears the client down completely before the next try, and the start is gated on a minimum largest-free-block check.
The budget is now visible from Central. Later on the day this post went out, a 12 KB static buffer broke the TLS handshake anyway; that story is in the sequel. Since then every boot record the device sends to Central carries free internal heap, the largest free internal block and free PSRAM, and every MQTT reconnect carries the client certificate's expiry. A regression in the internal budget now shows up in the audit log on the next boot, not in a handshake failure.
The boundary also protects the inverter from us. An early build wrote 0 A into the Deye's installer-set charge and discharge current registers on every mode change. The firmware now refuses, at the lowest Modbus layer, any write that touches those registers, and logs the attempt. More on that in the Modbus post update.
What came next. In September the firmware joined the main edge repository. A port to an ESP32-P4 wall panel (RISC-V, 32 MB PSRAM, wired Ethernet, two relays) is at the hardware bring-up stage, with the Modbus, MQTT and UI code shared with this build.
Built with esp-idf 5.4, LVGL 9, mbedtls, on a Waveshare ESP32-S3-Touch-LCD-4.3B BOX, polling a Deye SG04LP3 hybrid inverter. Full firmware source available on request.
@misc{oljasz2026esp32memory,
author = {Oljasz, Pawe{\l}},
title = {Enterprise-Grade Crypto on a \$20 Board: Securing Modbus With {ESP32-S3}},
howpublished = {Lab notebook, entry 010},
year = {2026},
month = jun,
url = {https://oljasz.com/blog/esp32-memory-engineering}
}Entry 010 / filed 15.06.2026 / stable URL, no tracking parameters.
