IoT

Zero-Trust IoT lab at home (ESP32 + OpenWrt + mTLS + IDS + Honeypot)

ESP32 talks MQTT over mTLS, the IoT Wi-Fi is isolated on its own VLAN, Suricata watches the traffic, a Cowrie honeypot lures attackers, and a small Flask dashboard ties the logs together.

Published 12 min read
Architecture diagram: an OpenWrt router with separate IoT and default VLANs; a Raspberry Pi running the Cowrie honeypot, the certificate authority and the Suricata IDS; an ESP32 storing its certificates in LittleFS; an mTLS tunnel carrying encrypted MQTT over TLS 1.2 to ThingsBoard; and alerts sent to Telegram

If you want the code and configs: GitHub → zero-trust-iot. I’ll keep refining it as I go.

If you don’t care about this blog post and just want the official report, here you go:

Google Drive → Detailed PDF Report

Why I did this (and what I wanted)

I needed an end-of-studies project for my bachelor’s degree that wasn’t another “blink the LED” project. And as someone who loves to bring networking and security into every discussion, I ended up doing all this work for a bachelor’s project, which is overkill. I SAW IT ON THE JURY’S FACES.

1. Network Segmentation (VLAN Isolation)

Open full-size image: Network diagram: the Raspberry Pi joins the IoT VLAN over Wi-Fi (10.0.20.254) and the default VLAN over Ethernet (10.0.0.254), the ESP32 with two sensors is on the IoT VLAN at 10.0.20.100, and a red X between the IoT VLAN (10.0.20.1) and the default VLAN (10.0.0.1) on the OpenWrt router shows they cannot reach each other
OpenWrt network segmentation
  • Default VLAN (10.0.0.0/24): default LAN
  • IoT VLAN (10.0.20.0/24): complete isolation for IoT devices
  • Firewall rules preventing inter-VLAN communication
  • OpenWrt router providing enterprise-grade network control

I bought a mid-range Xiaomi AX3000T router from AliExpress back in 2023 for about 30 USD.

I flashed it with the latest OpenWrt image, 24.10, and it was stable and so much more fun to work with than the stock firmware. OpenWrt is one of my go-tos now.

2. Certificate-Based Authentication (mTLS)

Open full-size image: X.509 credentials diagram: the ESP32 generates key.pem locally and sends a certificate signing request to the private certificate authority on the Raspberry Pi, which returns cert.pem; ThingsBoard trusts that CA through its device provisioning profile; a Wireshark excerpt shows the TLS 1.2 handshake between 10.0.20.100 and ThingsBoard at 3.127.14.137
Certificate Authority
  • Private Certificate Authority on a Raspberry Pi
  • X.509 certificates for the ESP32, which acts as a gateway for all sensors
  • Mutual TLS (mTLS) authentication, hence zero-trust
  • On-device key generation, ensuring keys never leave the device (the C++ code is here)

I installed Raspberry Pi OS Lite on my Raspberry Pi 5 (8 GB RAM) and used OpenSSL to generate the keys, certificates, and so on. So my whole PKI is hosted on the Pi.

At first, I generated the ESP32’s private key on the Raspberry Pi, used SFTP to transfer it to my PC, and then into the ESP32 code. But later I managed to make the ESP32 generate its own private key, to avoid any risk of it leaking. SFTP is secure, but a key that never travels is even more secure.

I also managed (I SWEAR TO GOD I SPENT 6 DAYS ON THIS ALONE) to upload the ESP32’s private key and certificate, plus the COMODO CA certificate that signed ThingsBoard’s server certificate, into the ESP32’s flash memory using LittleFS. The plugin is below:

Google Drive – Plugin.

3. Real-Time Threat Monitoring

Open full-size image: Diagram of Suricata on the Raspberry Pi inspecting all traffic entering and leaving it through the OpenWrt router, facing an internal and an external attacker, with its four features: IDS alerts, protocol transactions, extracted files and PCAP recording
Suricata IDS
Open full-size image: Diagram of the SSH honeypot: the Raspberry Pi’s own SSH moved to port 2277, OpenWrt forwards port 22 to the Cowrie container at 10.0.20.254, and an external attacker logs in with root:toor, receives a fake shell and has every command recorded in cowrie.json
Cowrie honeypot
  • Suricata IDS analyzing all network traffic
  • Cowrie honeypot detecting unauthorized access attempts
  • Automated alerting via Telegram with <2.3-second response time
  • Centralized logging with a Flask web dashboard

If I asked you which is better in general, blocking all SSH attempts or redirecting them to a honeypot, you would definitely say block them! I agree, but for drama and practice reasons, I set up port forwarding on my OpenWrt router to redirect all SSH requests to my honeypot.

4. End-to-End Encryption

Open full-size image: Wireshark capture filtered on TLS between the ESP32 (10.0.20.100) and ThingsBoard (3.127.14.137, port 8883): a complete TLS 1.2 handshake followed by Application Data records
MQTT over TLS
  • TLS 1.2 with strong cipher suites (AES-256-GCM)
  • Perfect Forward Secrecy protecting past communications
  • Certificate validation on every connection
  • Encrypted MQTT communication with cloud platforms

Weeks of work, but it all felt like nothing when I saw that list of packets in Wireshark. Look how beautiful this is!

Google Drive – PCAP Captures.

Results Summary

MetricResult
Total Cost$150 USD
Setup Time3 weeks (mostly debugging)
Threat Detection Rate100% (all test attacks detected)
Average Alert Time2.3 seconds
Raspberry Pi CPU Usage6% (full security stack)
Network Isolation100% (zero cross-VLAN communication)
ESP32 TLS Overhead<5%
Supported IoT Devices50-70 (limited by router RAM)

Architecture (one paragraph you can screenshot)

OpenWrt splits the network into VLANs: 10.0.20.0/24 for IoT and 10.0.0.0/24 for the default LAN. A Raspberry Pi runs a private CA, the Suricata IDS, a Cowrie honeypot (Docker), and a small Flask dashboard. The ESP32 stores its X.509 certificate and key in flash memory using LittleFS, and publishes telemetry to ThingsBoard over MQTT-over-TLS 1.2 with mutual authentication on mqtt.eu.thingsboard.cloud:8883. Anything not explicitly allowed is blocked. If something noisy appears, it shows up in the dashboard (and in a Telegram channel; I bring Telegram into every project).

The network: OpenWrt and why VLANs

Open full-size image: OpenWrt LuCI Interfaces page with two static interfaces: IOT_VLAN on phy0-ap1 at 10.0.20.1/24, and lan on br-lan at 10.0.0.1/24
OpenWrt VLANs

OpenWrt was the fastest way to get proper segmentation at home without buying enterprise gear.

What I actually changed

/etc/config/network

config interface 'iot'
option proto 'static'
option ipaddr '10.0.20.1'
option netmask '255.255.255.0'
option type 'bridge'

Tag/untag your switch ports as needed (device-specific).

/etc/config/firewall

config zone
option name 'iot'
option network 'iot'
option input 'REJECT' # no inbound to router
option output 'ACCEPT' # can reach WAN
option forward 'REJECT' # no lateral movement

You can find all the OpenWrt configuration here:

GitHub → OpenWrt Configuration

Open full-size image: OpenWrt shell running tcpdump -i phy0-ap1 -w /tmp/mtls_capture.pcap -n host 10.0.20.100, which captured 57 packets with none dropped by the kernel
OpenWrt packet capture

Quick captures from the router (these helped a lot while debugging TLS):

Terminal window
opkg update
opkg install tcpdump openssh-sftp-server
# Capture on the AP radio, single host filtered the 10.0.20.100 is ESP32 static IP
tcpdump -i phy0-ap1 -w /tmp/mtls_capture.pcap -n 'host 10.0.20.100'
# Pull the file
scp root@10.0.0.254:/tmp/mtls_capture.pcap ./captures/

Whenever I faced an issue with the network or mTLS, I started capturing on OpenWrt and analyzing in Wireshark, and that was a game changer for me. Here are some of the captures I took while working on this project:

Google Drive – PCAP Captures.

ESP32 side (LittleFS + WiFiClientSecure + PubSubClient)

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <PubSubClient.h>
#include <LittleFS.h>
#include <time.h>
// Wi-Fi network credentials (set your SSID and password)
const char* WIFI_SSID = "IOT_VLAN_SSID";
const char* WIFI_PASSWORD = "00001111";
// ThingsBoard MQTT server details
const char* THINGSBOARD_HOST = "mqtt.eu.thingsboard.cloud"; // adjust for region if needed, e.g. "mqtt.eu.thingsboard.cloud"
const uint16_t THINGSBOARD_PORT = 8883;
// File paths in LittleFS
const char* CA_CERT_PATH = "/aaa.pem";
const char* CLIENT_CERT_PATH = "/cert.pem";
const char* PRIVATE_KEY_PATH = "/key.pem";
// Global variables for TLS certificate contents
String caCert;
String clientCert;
String privateKey;
// WiFi and MQTT clients
WiFiClientSecure secureClient;
PubSubClient mqttClient(secureClient);
// Function to read a text file from LittleFS into a String
bool readFileToString(fs::FS &fs, const char* path, String &dest) {
File file = fs.open(path, "r");
if (!file) {
Serial.printf("Failed to open file: %s\n", path);
return false;
}
dest.clear();
// Reserve capacity to avoid fragmentation (optional)
dest.reserve(file.size());
while (file.available()) {
// Read file character by character to preserve all contents (including newlines)
dest += char(file.read());
}
file.close();
// Debug: print file size loaded
Serial.printf("Loaded %s (%d bytes)\n", path, dest.length());
return true;
}
// Ensure time is initialized (required for TLS)
void syncTime() {
configTime(0, 0, "pool.ntp.org", "time.nist.gov"); // UTC timezone
Serial.print("Synchronizing time via NTP");
struct tm timeinfo;
const uint8_t MAX_RETRIES = 30;
uint8_t attempt = 0;
// Wait for time to be set or until MAX_RETRIES reached
while (attempt < MAX_RETRIES) {
if (getLocalTime(&timeinfo)) {
Serial.println(" - Time synchronized!");
Serial.printf("Current time: %04d-%02d-%02d %02d:%02d:%02d UTC\n",
timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday,
timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
return;
}
Serial.print(".");
delay(500);
attempt++;
}
Serial.println(" - Failed to synchronize time!");
}
// Attempt to (re)connect to ThingsBoard MQTT
void reconnectMQTT() {
// Generate a unique client ID based on MAC address
String clientId = "ESP32-";
clientId += WiFi.macAddress();
clientId.replace(":", ""); // remove colons from MAC
while (!mqttClient.connected()) {
Serial.print("Connecting to ThingsBoard MQTT...");
// Note: using client certificate authentication, no username/password required
if (mqttClient.connect(clientId.c_str())) {
Serial.println(" connected.");
// You can subscribe to topics or publish a message here if needed
} else {
Serial.printf(" failed, rc=%d. Retrying in 5 seconds...\n", mqttClient.state());
delay(5000);
}
}
}
void setup() {
Serial.begin(115200);
delay(100); // small delay to let Serial init
Serial.println();
Serial.println("=== Device booting... ===");
// Initialize LittleFS
Serial.println("Mounting LittleFS...");
if (!LittleFS.begin()) {
Serial.println("ERROR: LittleFS Mount failed! Halting.");
while (true) { delay(100); }
}
Serial.println("LittleFS mounted successfully.");
// Load TLS certificates/keys from LittleFS
Serial.println("Reading TLS credentials from LittleFS...");
bool ok = true;
ok &= readFileToString(LittleFS, CA_CERT_PATH, caCert);
ok &= readFileToString(LittleFS, CLIENT_CERT_PATH, clientCert);
ok &= readFileToString(LittleFS, PRIVATE_KEY_PATH, privateKey);
if (!ok) {
Serial.println("ERROR: Failed to load one or more PEM files. Check LittleFS contents.");
while (true) { delay(100); }
}
// Connect to Wi-Fi
Serial.printf("Connecting to WiFi SSID: %s\n", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
// Wait for connection
uint8_t attempt = 0;
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
if (++attempt % 40 == 0) { // print a newline every 40 * 0.5s = 20s
Serial.println();
}
}
Serial.println("\nWiFi connected.");
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
// Sync time for TLS
syncTime();
// Configure the secure client with certificates
Serial.println("Configuring TLS credentials...");
secureClient.setCACert(caCert.c_str());
secureClient.setCertificate(clientCert.c_str());
secureClient.setPrivateKey(privateKey.c_str());
// Optionally, disable certificate verification for testing (NOT recommended in production)
// secureClient.setInsecure(); // **Do not use in production, for debug only**
// Configure MQTT server and port
mqttClient.setServer(THINGSBOARD_HOST, THINGSBOARD_PORT);
// Optionally, set callback for incoming messages (not used in this example)
// mqttClient.setCallback(mqttCallback);
// Attempt initial MQTT connection
reconnectMQTT();
}
void loop() {
if (!mqttClient.connected()) {
reconnectMQTT();
}
mqttClient.loop();
static unsigned long lastSend = 0;
if (millis() - lastSend > 10000) {
lastSend = millis();
const char* payload = "{\"temperature\":99}";
mqttClient.publish("v1/devices/me/telemetry", payload);
Serial.println("↗ Published telemetry to ThingsBoard");
}
}

CLI smoke test (good for diagnosing ThingsBoard profile issues):

Terminal window
mosquitto_pub -d -h mqtt.eu.thingsboard.cloud -p 8883 --cafile /etc/ssl/certs/ca-certificates.crt --cert ~/ca/clients/esp32_01/esp32.cert.pem --key ~/ca/clients/esp32_01/esp32.key.pem -i esp32-01-$(date +%s) -t v1/devices/me/telemetry -m '{"diag":"ok"}'

If you see (5) not authorised, re‑check the Device Profile and CA. If you see (2) identifier rejected, simplify the -i value.

Detection: Suricata IDS + Cowrie honeypot (and a tiny dashboard)

I run both on the Pi (Docker for Cowrie). Suricata watches the IoT VLAN and default LAN; Cowrie attracts the bored internet.

Suricata essentials

/etc/suricata/suricata.yaml (the bit everyone forgets)

vars:
address-groups:
HOME_NET: "[10.0.20.0/24, 10.0.0.0/24]"
EXTERNAL_NET: "!$HOME_NET"

Update rules and start:

Terminal window
sudo suricata-update
sudo systemctl enable --now suricata
sudo suricata -T -c /etc/suricata/suricata.yaml # test config

Generate a harmless alert to validate the pipeline:

Terminal window
curl http://testmyids.com # should fire GPL ATTACK_RESPONSE in eve.json

If you don’t see it in your dashboard, confirm the eve.json path and permissions, the HOME_NET ranges, and that your parser selects event_type == "alert".

Cowrie in Docker

Terminal window
docker run -d --name honeypot-container \
-p 22:2222 -p 23:2223 \
-v ~/cowrie_data:/cowrie/var \
--restart always cowrie/cowrie

I pull the structured logs from the container and display sessions (successful/failed logins, commands tried) in a Flask view next to the IDS table.

The Six-Day War: Debugging the ESP32 mTLS Connection

This was, without a doubt, the most challenging part of the project. I had a working network, a working CA, and a working cloud endpoint. My mosquitto_pub test from the Raspberry Pi, using the exact same certificates, connected to ThingsBoard flawlessly.

Yet the ESP32 refused to connect, repeatedly failing with a generic rc=-2 error. This kicked off a 6-day debugging marathon.

  • Was it the firewall? No, the mosquitto_pub test proved traffic could get through.
  • Was it the certificates? No, the openssl verify command and the mosquitto_pub test proved the chain of trust was perfect.
  • Was it the ESP32’s clock? This was a major issue. TLS certificates require accurate time, and the initial code was failing because the ESP32’s clock wasn’t synchronized. I fixed this by adding NTP time sync logic. The error persisted.
  • Was it DNS? The time sync was failing because the ESP32 couldn’t resolve pool.ntp.org. I bypassed this by using a direct IP (216.239.35.0). The error still persisted.

The final breakthrough came from analyzing Wireshark captures of the failed handshakes. The ESP32 was establishing a TCP connection and then immediately sending a [FIN, ACK] packet to tear it down before the TLS handshake even began. This pointed to one final culprit: RAM exhaustion. The ESP32’s WiFiClientSecure library was likely failing to allocate enough memory to handle the multiple large certificates required for the handshake.

The final, working code came from researching alternative MQTT libraries and finding a more memory-efficient implementation that could successfully complete the handshake.

The “After” Picture: A Secure, Encrypted Tunnel

With the final code, it all came together. The ESP32 connected, and another Wireshark capture showed the beautiful result.

Open full-size image: Wireshark packet details for an MQTT message from the ESP32 to ThingsBoard on port 8883: the protocol column shows only TLSv1.2 and the payload appears as Encrypted Application Data
MQTT over TLS

Success: the MQTT traffic is now unreadable, hidden inside a secure TLSv1.2 tunnel.

As you can see, Wireshark no longer identifies the protocol as “MQTT.” It only sees TLSv1.2. The sensitive data is now hidden inside the “Encrypted Application Data” payload. This is the tangible proof of end-to-end encryption.

Performance notes (real numbers)

Implementing comprehensive security doesn’t require expensive hardware:

  • Raspberry Pi 5 (8GB): 6% CPU usage with the full security stack
  • OpenWrt Router (256MB RAM): handles 50-70 IoT devices
  • ESP32: <5% overhead for TLS encryption
  • Total Cost: ~$162 USD for the complete infrastructure

Comparison with Enterprise Solutions

FeatureOur ImplementationEnterprise SolutionCost Difference
Network SegmentationOpenWrt VLANsCisco/Juniper-$5,000+
Certificate AuthorityOpenSSLMicrosoft CA-$2,000+
IDS/IPSSuricataCisco Firepower-$10,000+
HoneypotCowrieCommercial-$1,000+
Total Savings$162$18,000+$17,838

Deployment Guide: From Lab to Production

Phase 1: Network Foundation

  1. Deploy an OpenWrt router with VLAN configuration
  2. Implement firewall rules and access controls
  3. Test network isolation thoroughly

Phase 2: Security Infrastructure

  1. Set up the Raspberry Pi with Docker
  2. Deploy the Certificate Authority (ephemeral container)
  3. Install and configure Suricata IDS
  4. Deploy the Cowrie honeypot

Phase 3: Device Integration

  1. Generate device certificates
  2. Implement TLS communication
  3. Configure cloud platform integration
  4. Test end-to-end encryption

Phase 4: Monitoring and Maintenance

  1. Set up alerting systems
  2. Configure log aggregation
  3. Implement backup procedures
  4. Schedule security updates
Open full-size image: Flask dashboard, Suricata IDS Alerts tab: rows of “GPL ATTACK_RESPONSE id check returned root” alerts from 217.160.0.187:80 on 2025-07-06, classified as Potentially Bad Traffic with priority 2
IDS logs
Open full-size image: Flask dashboard, Cowrie Honeypot Logs tab: two SSH sessions from 10.0.0.121 on 2025-07-06, each a successful root login followed by commands such as ls, whoami, uname -a and exit
Honeypot logs

What I’d improve next

  • Automatic cert rotation (short‑lived certs) + revocation (CRL isn’t supported in ThingsBoard, so I need a workaround)
  • Anomaly detection on Suricata flows (stats first, ML later)
  • A proper Grafana view on top of eve.json + Cowrie

Appendix: quick OpenWrt cheats I kept using

Terminal window
# See routes and VLAN interfaces
ip a; ip r
# Verify DNS/SNI reachability to TB
nslookup mqtt.eu.thingsboard.cloud
openssl s_client -connect mqtt.eu.thingsboard.cloud:8883 -servername mqtt.eu.thingsboard.cloud -alpn mqtt
# Copy pcaps off the router
scp root@10.0.0.254:/tmp/mtls_capture.pcap .

You can absolutely do this at home with open-source tools and a weekend. (EVIL LAUGH: you will hate me after that weekend, for sure.)

I believe in the power of open source and sharing knowledge. I’ve documented the entire process and made all the configuration files and code available in my GitHub repository.

About the Author

I’m Radouane ELHAJALI. This was my end-of-studies project for my bachelor’s degree in IoT and Intelligent Systems. I’m now in my fourth year of cybersecurity engineering.

Connect with me:


Keywords: Zero Trust IoT, IoT security architecture, cybersecurity for IoT devices, MQTT security, TLS encryption, network segmentation, VLAN configuration, Suricata IDS, Cowrie honeypot, certificate authority, ESP32 security, Raspberry Pi security, mTLS authentication, IoT threat detection, enterprise IoT security