Secure MQTT with TLS in Node.js: Lab 6 Guide

In Lab 3 you captured MQTT credentials in Wireshark in under 30 seconds. In this lab you implement the correct alternative: MQTT over TLS with root CA certificate verification using Node.js. You will see what Wireshark shows when encryption is active (the TLS handshake followed by unreadable application data), verify that the client refuses to connect when presented with an invalid certificate and understand why certificate validation is as important as the encryption itself. This is Lab 6 of the Embedded Systems Security course series, covering communication security from Section 6.
How TLS Secures MQTT Communication
TLS (Transport Layer Security) wraps the MQTT TCP connection in an encrypted channel. Before any MQTT data is exchanged, the client and server perform a handshake that: verifies the server’s identity using its X.509 certificate signed by a trusted Certificate Authority, negotiates a cipher suite both parties support, and establishes a session key using asymmetric cryptography (the session key itself never travels over the network). All subsequent MQTT data — topics, payloads, credentials, commands — travels encrypted using that session key.
The critical step that many implementations get wrong is certificate verification. Establishing TLS without verifying the server’s certificate prevents passive eavesdropping but does nothing against man-in-the-middle attacks. An attacker who can intercept the TCP connection can present their own TLS certificate and read all the “encrypted” traffic. Certificate verification — checking that the server’s certificate is signed by a trusted root CA and that the hostname matches — is what prevents this. The ESP32’s WiFiClientSecure supports both modes; this lab uses proper verification.
Equipment and Prerequisites
You need a computer with Node.js (v18 or later) installed, Wireshark installed and a WiFi or wired network connection with internet access (the lab uses the HiveMQ public MQTT broker). Install the mqtt package from npm. Node.js’s built-in tls module handles certificate verification automatically when you pass the correct options to the MQTT client.
Complete Node.js Code: Secure MQTT Client
The code connects to the HiveMQ public broker on port 8883 (the standard MQTT-over-TLS port) using the mqtt npm package configured with the broker’s root CA certificate. It publishes simulated temperature and humidity readings every 5 seconds and subscribes to a command topic. Node.js’s built-in TLS stack uses the system root CA bundle by default; we supply the ISRG Root X1 certificate explicitly so the lab works identically on every OS and the verification chain is visible in the code. First install the dependency:
npm init -y
npm install mqtt
Save the following as secure-mqtt-client.js and run it with node secure-mqtt-client.js:
/**
* secure-mqtt-client.js
* Secure MQTT with TLS/SSL Encryption — Lab 6
* Demonstrates proper encrypted IoT communication.
* Connects to HiveMQ public broker on port 8883 (TLS).
*
* Run: node secure-mqtt-client.js
* Dep: npm install mqtt
*/
"use strict";
const mqtt = require("mqtt");
// === MQTT BROKER ===
const BROKER_HOST = "broker.hivemq.com";
const BROKER_PORT = 8883; // TLS port — NOT 1883
const CLIENT_ID = "NodeJS_Secure_Lab6_" + Math.random().toString(16).slice(2, 8);
const MQTT_USERNAME = "hivemq";
const MQTT_PASSWORD = "hivemq123";
// === MQTT TOPICS ===
const TOPIC_TEMP = "iot/secure/lab6/temperature";
const TOPIC_HUMIDITY = "iot/secure/lab6/humidity";
const TOPIC_STATUS = "iot/secure/lab6/status";
const TOPIC_COMMANDS = "iot/secure/lab6/commands";
// === ROOT CA CERTIFICATE ===
// ISRG Root X1 — signs Let's Encrypt certificates used by HiveMQ.
// Supplying this means Node.js verifies the broker's identity before
// any MQTT data is exchanged. Removing it enables MITM attacks.
const ROOT_CA = `-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----`;
// ================================================================
// TLS connection options
// ca: the root CA certificate — this is what enables certificate
// verification and MITM protection.
// rejectUnauthorized: true (default) — connection is aborted if
// the broker's cert does not chain to the CA above.
// ================================================================
const tlsOptions = {
host: BROKER_HOST,
port: BROKER_PORT,
protocol: "mqtts", // mqtt over TLS — uses port 8883
clientId: CLIENT_ID,
username: MQTT_USERNAME,
password: MQTT_PASSWORD,
ca: ROOT_CA, // supply root CA for verification
rejectUnauthorized: true, // NEVER set false in production
keepalive: 30,
reconnectPeriod: 5000,
};
console.log("\n=== Secure MQTT with TLS/SSL — Lab 6 ===");
console.log("All data encrypted before transmission.");
console.log("Wireshark will show TLS records, not readable content.\n");
console.log("TLS Configuration:");
console.log(" Root CA certificate: LOADED");
console.log(" Certificate chain: WILL BE VERIFIED");
console.log(" MITM protection: ACTIVE\n");
console.log(`=== MQTT Connection (TLS/SSL) ===`);
console.log(`Broker: ${BROKER_HOST}:${BROKER_PORT}`);
const client = mqtt.connect(tlsOptions);
// ----------------------------------------------------------------
// Connection established — TLS handshake and cert check succeeded
// ----------------------------------------------------------------
client.on("connect", () => {
console.log("Connected!");
console.log("TLS encryption: ACTIVE");
console.log("Certificate: VERIFIED");
client.subscribe(TOPIC_COMMANDS, { qos: 1 }, (err) => {
if (!err) console.log(`Subscribed to: ${TOPIC_COMMANDS}\n`);
});
publishStatus();
startPublishLoop();
});
// ----------------------------------------------------------------
// Incoming command handler
// ----------------------------------------------------------------
client.on("message", (topic, payload) => {
const message = payload.toString();
console.log("\n=== Incoming MQTT Command (ENCRYPTED in transit) ===");
console.log(`Topic: ${topic}`);
console.log(`Command: ${message}`);
if (topic === TOPIC_COMMANDS) {
if (message === "STATUS") {
publishStatus();
} else if (message === "STOP") {
console.log("Stop command received. Disconnecting...");
client.end();
process.exit(0);
}
}
});
// ----------------------------------------------------------------
// TLS / connection error handler
// ----------------------------------------------------------------
client.on("error", (err) => {
console.error("\n[ERROR] Connection failed:", err.message);
if (err.code === "CERT_HAS_EXPIRED" || err.code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE") {
console.error("Certificate verification FAILED — connection refused.");
console.error("This is the correct behaviour: an invalid cert means a potential MITM.");
}
});
client.on("reconnect", () => {
console.log("Reconnecting...");
});
// ----------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------
function publishStatus() {
const status = JSON.stringify({
device: CLIENT_ID,
uptime_s: Math.floor(process.uptime()),
memory_mb: (process.memoryUsage().rss / 1024 / 1024).toFixed(1),
node_version: process.version,
});
client.publish(TOPIC_STATUS, status, { qos: 1 });
console.log("Published status (ENCRYPTED)");
}
function randomBetween(min, max) {
return (Math.random() * (max - min) + min).toFixed(1);
}
function startPublishLoop() {
setInterval(() => {
const temperature = randomBetween(20.0, 32.0);
const humidity = randomBetween(40.0, 80.0);
console.log("\n--- Publishing (ENCRYPTED over TLS) ---");
client.publish(TOPIC_TEMP, temperature, { qos: 1 }, () => {
console.log(`Temperature: ${temperature}C [ENCRYPTED]`);
});
client.publish(TOPIC_HUMIDITY, humidity, { qos: 1 }, () => {
console.log(`Humidity: ${humidity}% [ENCRYPTED]`);
});
console.log("Wireshark shows binary ciphertext, not these values.");
}, 5000);
}
Understanding the TLS Configuration
The three options that make the connection secure are:
// 1. Supply the root CA certificate that signed the broker's certificate
const tlsOptions = {
ca: ROOT_CA, // enables chain-of-trust verification
// 2. Use the mqtts protocol so the mqtt package opens port 8883 over TLS
protocol: "mqtts", // mqtts, not mqtt — uses port 8883 not 1883
// 3. Keep rejectUnauthorized true (this is the default but always be explicit)
rejectUnauthorized: true, // connection aborted if cert check fails
};
const client = mqtt.connect(tlsOptions);
// mqtt.connect performs the full TLS handshake before emitting "connect"
What happens if you use protocol: "mqtt" instead of "mqtts": the connection succeeds on port 1883 but uses no encryption — all MQTT traffic is plaintext. This is Lab 3’s scenario. What happens if you set rejectUnauthorized: false: the connection uses TLS encryption but skips certificate verification. An attacker can perform MITM by presenting their own certificate and the client will accept it. Never set rejectUnauthorized: false in production. What happens if you supply the wrong root CA: the TLS handshake fails with a certificate error and the connection is refused. This is the correct security behaviour — the client cannot be tricked into connecting to an impersonator.
Wireshark Comparison: Before and After TLS
Open Wireshark and capture on your WiFi interface. Apply the filter tcp.port == 8883 to see the TLS traffic.
What you see with TLS (this lab):
# Wireshark filter: tcp.port == 8883
# TLS Handshake packets (visible):
TLSv1.3 Client Hello
TLSv1.3 Server Hello
TLSv1.3 Certificate
TLSv1.3 Certificate Verify
TLSv1.3 Finished
# Application data (ENCRYPTED — cannot read):
TLSv1.3 Application Data
Encrypted Application Data: a7f3b2c9d4e5f6a7b8c9d0e1f2a3b4c5...
[Cannot decrypt without the session private key]
What you would see without TLS (Lab 3 equivalent for MQTT):
# Wireshark filter: tcp.port == 1883
MQTT Connect Command (Username: device_fleet, Password: MqttP@ss2024!)
MQTT Publish Message Topic: iot/sensor/temp, Message: 25.3
MQTT Publish Message Topic: iot/sensor/humidity, Message: 62.4
The TLS handshake is visible to Wireshark — you can see the cipher suite negotiation, the certificate exchange and the timing. The application data (MQTT connect credentials, published topics and payloads, subscribed messages) is completely opaque. Wireshark shows encrypted binary data. There is no filter or feature in Wireshark that can decrypt it without the private key.
Why Certificate Validation Matters
Consider what happens when the Node.js client connects to broker.hivemq.com on port 8883 without certificate verification: the TLS handshake completes, the session is encrypted but the client has no way to confirm it is talking to the real HiveMQ broker rather than an attacker’s server. An attacker who can redirect DNS or intercept the TCP connection presents their own TLS certificate. The client connects, the traffic is “encrypted” — but it is encrypted to the attacker, who can read all MQTT credentials and payloads and relay them to the real broker, making the attack invisible.
Certificate verification prevents this by checking: (1) is the server’s certificate signed by a CA in the trusted root list? (2) does the certificate’s Common Name or Subject Alternative Name match the hostname we connected to? Only the real broker has a certificate that passes both checks. The attacker’s certificate fails check (1) unless the attacker has compromised the root CA, which is a much harder attack. This is why the ca: ROOT_CA and rejectUnauthorized: true options are not optional — they are what transforms TLS from eavesdropping-prevention into MITM-prevention.
Execution Steps
Install the dependency with npm install mqtt and run the script with node secure-mqtt-client.js. Step 1: watch the TLS configuration messages printed immediately on startup. Step 2: observe the MQTT connection — you will see “Connected!”, “TLS encryption: ACTIVE” and “Certificate: VERIFIED” in the terminal. Step 3: open Wireshark with filter tcp.port == 8883 and observe that sensor data publications appear only as encrypted TLS Application Data records, not as readable MQTT packets. Step 4: to verify certificate validation, temporarily replace the ROOT_CA constant with garbage text and observe the connection refused with a Node.js TLS certificate error in the console.
Expected Serial Monitor Output
=== Secure MQTT with TLS/SSL — Lab 6 ===
All data encrypted before transmission.
Wireshark will show TLS records, not readable content.
TLS Configuration:
Root CA certificate: LOADED
Certificate chain: WILL BE VERIFIED
MITM protection: ACTIVE
=== MQTT Connection (TLS/SSL) ===
Broker: broker.hivemq.com:8883
Connected!
TLS encryption: ACTIVE
Certificate: VERIFIED
Subscribed to: iot/secure/lab6/commands
Published status (ENCRYPTED)
--- Publishing (ENCRYPTED over TLS) ---
Temperature: 25.3C [ENCRYPTED]
Humidity: 62.4% [ENCRYPTED]
Wireshark shows binary ciphertext, not these values.
Advanced: Mutual TLS and Certificate Pinning
The lab implements one-way TLS: the client verifies the server’s certificate but the server does not verify the client. For higher-security deployments, mutual TLS (mTLS) adds client certificate authentication so the broker can reject connection attempts from unknown devices:
// Mutual TLS: supply CA cert AND a client cert + key
const mtlsOptions = {
host: BROKER_HOST,
port: BROKER_PORT,
protocol: "mqtts",
ca: fs.readFileSync("root_ca.pem"), // verify server
cert: fs.readFileSync("client_cert.pem"), // identify client
key: fs.readFileSync("client_key.pem"), // sign client handshake
rejectUnauthorized: true,
};
// Now the broker can reject connections from devices without valid client certs
Certificate pinning goes further: instead of trusting any certificate signed by the root CA, the client only accepts a specific certificate’s fingerprint. Node.js exposes the peer certificate after the TLS handshake via the socket:
// After connect, retrieve the broker's certificate and verify its fingerprint
client.on("connect", () => {
const socket = client.stream; // underlying TLS socket
const cert = socket.getPeerCertificate();
const EXPECTED_FINGERPRINT = "AA:BB:CC:DD:EE:FF:..."; // SHA-256 of broker cert
if (cert.fingerprint256 !== EXPECTED_FINGERPRINT) {
console.error("Certificate fingerprint mismatch! Aborting.");
client.end();
process.exit(1);
// Do not publish any data — connection may be compromised
}
// Fingerprint matches — proceed normally
});
Discussion Questions
What is still visible in Wireshark with TLS? The TLS handshake: which cipher suites the client supports, which one the server selected, the server certificate and its chain, the timing of connections and reconnections, and the approximate size of each encrypted record. What is not visible: MQTT topics, payloads, credentials, device identity within MQTT, and any application data.
How does TLS prevent man-in-the-middle? The server’s certificate is signed by a root CA that the client trusts. Only the real server has the private key matching the certificate’s public key. An impersonator either presents a certificate that fails the CA signature check, or presents a valid certificate for a different hostname that fails the hostname check. Either way the client refuses the connection. Without certificate verification, the client accepts any certificate — making MITM trivial.
What is the performance cost of TLS? The initial handshake takes roughly 200–400 ms on a typical laptop connecting to a remote broker, which includes one or two round trips and the certificate chain verification. Ongoing encryption adds negligible CPU overhead on modern hardware. Node.js uses OpenSSL under the hood, which takes full advantage of hardware AES acceleration available on all modern x86 and ARM processors. The performance cost is negligible compared to the security benefit.
What if the root CA certificate expires? The root CA embedded in the firmware has a validity period. When it expires, the firmware will refuse all new connections. This is an argument for certificate management at scale: OTA updates must include updated root CA certificates before the current ones expire. Plan certificate rotation into your OTA and maintenance process.
Key Takeaways
TLS encrypts all MQTT communication — topics, payloads, credentials and commands — making captured packets unreadable in Wireshark. Certificate verification is not optional: without it, TLS prevents eavesdropping but not MITM. Always use port 8883 for MQTT over TLS, not port 1883. Never set rejectUnauthorized: false in production code — it silently disables certificate verification. Node.js’s built-in TLS stack (OpenSSL) makes secure MQTT trivial to implement with just the mqtt npm package and the correct options object. This lab paired with Lab 3 provides the complete before-and-after comparison: identical application functionality, fundamentally different network security posture.