Secure MQTT Communication with ESP32: AES Encryption & Decryption (Step-by-Step)
Here is a pdf you can download has all the information about the encryption and all you need step-by-step to understand the encryption and encoding of the AES and Base64
Chapter 1
1 What This Project Does
This project demonstrates a secure communication system between two ESP32 devices using MQTT protocole and:
- AES-128: Fast and sufficient for many ESP32 use cases.
- IV: Must be unique per message. Safe to send with ciphertext.
- Padding: PKCS7 ensures variable-length plaintext is aligned to 16-byte blocks.
- Key management: Avoid hardcoding for production, rotate keys periodically.
The subscriber receives an encrypted message in the format:
Base64( IV | Ciphertext )
Then
- Decodes Base64
- Splits the first 16 bytes as the IV
- Decrypts the remaining ciphertext using AES-CBC
- Removes PKCS7 padding
- Prints the plaintext
- Data Flow
Why/How
Publisher: Generates plaintext → encrypts with AES-CBC using a random IV → concatenates IV + ciphertext → Base64 encodes → publishes to MQTT.
Subscriber: Receives Base64 payload → decodes → splits IV and ciphertext → decrypts → prints plaintext.
Security: AES key is shared between publisher and subscriber; IV is public but must be unique for each message to ensure proper security.
Chapter 2
Libraries Used
Each library plays a critical role. Below are their purposes and alternatives.
2.1 <WiFi.h>
Purpose
Enables ESP32 to connect to Wi-Fi networks. It’s part of the ESP32 core and provides low-level networking functions.
2.2 <PubSubClient.h>
Purpose
A lightweight MQTT client library for Arduino. Handles:
- Connecting to an MQTT broker
- • Subscribing to topics
- • Receiving messages via callback
- • Publishing messages
2.3 <AESLib.h>
Purpose
Provides AES encryption and decryption in multiple modes (CBC, ECB, CTR). This project uses CBC mode with PKCS7 padding. It supports key sizes of 128, 192, and 256 bits.
Key Features
- CBC mode requires an IV
- • PKCS7 padding ensures block alignment
- • IV is modified during encryption (so we copy it)
2.4 “libb64/cdecode.h”
Purpose
Efficient Base64 decoding for embedded systems. Converts ASCII Base64 strings back into raw binary data.
Why This Over Others?
- Lightweight and memory-efficient
- No dynamic allocation
- Works well on ESP32 with small stack
Chapter 3
Full Source Code Listing
3.1 ESP32 MQTT AES-CBC Subscriber with Base64 Decode
#include <WiFi.h>
#include <PubSubClient.h>
#include <AESLib.h>
#include <libb64/cdecode.h>
const char* ssid = "ooredoo-6CCC29–5G";
const char* pass = "79B421EFWm35";
const char* mqtt_server = "broker.emqx.io";
const char* topic = "esp32pub";
WiFiClient espClient;
PubSubClient client(espClient);
// AES key must match publisher
AESLib aesLib;
byte aes_key[] = {
0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6,
0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, 0x4F, 0x3C
};
String decrypt_message(byte* ciphertext, uint16_t msgLen, byte* iv) {
byte decrypted[128];
uint16_t decLen = aesLib.decrypt(
ciphertext,
msgLen,
decrypted,
aes_key,
sizeof(aes_key),
iv
);
decrypted[decLen] = 0;
return String((char*)decrypted);
}
void callback(char* topic, byte* payload, unsigned int length) {
Serial.print("[arrived] ");
Serial.print(length);
Serial.println(" bytes");
// Base64 decode
byte decoded[128];
int decodedLength = base64_decode_chars((char*)payload, length, (char*)decoded);
if (decodedLength < 32 || (decodedLength - 16) % 16 != 0) {
Serial.println("Invalid message length");
return;
}
// Extract IV
byte iv[16];
memcpy(iv, decoded, 16);
// Extract ciphertext
uint16_t ciphertextLen = decodedLength - 16;
byte ciphertext[ciphertextLen];
memcpy(ciphertext, decoded + 16, ciphertextLen);
// Print IV
Serial.print("IV: ");
for (int i = 0; i < 16; i++) {
if (iv[i] < 0x10) Serial.print("0");
Serial.print(iv[i], HEX);
}
// Print Ciphertext
Serial.print(" | ");
for (int i = 0; i < ciphertextLen; i++) {
if (ciphertext[i] < 0x10) Serial.print("0");
Serial.print(ciphertext[i], HEX);
}
Serial.println();
// Copy IV for decryption (AES modifies it)
byte iv_copy[16];
memcpy(iv_copy, iv, 16);
// Decrypt
String decrypted = decrypt_message(ciphertext, ciphertextLen, iv_copy);
Serial.print("Decrypted: ");
Serial.println(decrypted);
}
void reconnect() {
while (!client.connected()) {
Serial.print("Connecting to MQTT…");
if (client.connect("ESP32Sub")) {
Serial.println("connected");
client.subscribe(topic);
} else {
Serial.print("failed, rc=");
Serial.print(client.state());
Serial.println(" retry in 5s");
delay(5000);
}
}
}
void setup() {
Serial.begin(115200);
Serial.print("Connecting to ");
Serial.println(ssid);
WiFi.begin(ssid, pass);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println(" connected");
client.setServer(mqtt_server, 1883);
client.setCallback(callback);
aesLib.set_paddingmode(paddingMode::PKCS7); // PKCS7 padding
}
void loop() {
if (!client.connected()) reconnect();
client.loop();
}Chapter 4
Line-by-Line Walkthrough
4.1 Includes
#include <WiFi.h> // ESP32 WiFi stack
#include <PubSubClient.h> // MQTT client (async)
#include <AESLib.h> // AES-CBC decrypt, PKCS7 padding
#include <libb64/cdecode.h> // Base64 decoderWhy/How
Why these? WiFi.h connects to the network. PubSubClient handles MQTT communication. AESLib performs decryption. libb64 safely converts Base64 to binary for decryption.
4.2 Configuration Constants
const char* ssid = "ooredoo-6CCC29–5G";
const char* pass = "79B421EFWm35";
const char* mqtt_server = "broker.emqx.io";
const char* topic = "esp32pub";Tip: Replace these with your own Wi-Fi and MQTT settings. broker.emqx.io is a free public broker. For production, use TLS (port 8883).
4.3 Global Clients
WiFiClient espClient;
PubSubClient client(espClient);Why/How
WiFiClient is used by PubSubClient to manage TCP connections. This is the standard pattern in Arduino MQTT.
4.4 AES Key Choice
AESLib aesLib;
byte aes_key[] = {
0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6,
0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, 0x4F, 0x3C
};Note: This is the famous AES-128 test key from NIST FIPS 197. It’s 16 bytes (128 bits), perfect for AES-128. Both publisher and subscriber must use the same key. Never hardcode keys in production — use secure storage or key exchange.
4.5 Decryption Helper Function
String decrypt_message(byte* ciphertext, uint16_t msgLen, byte* iv) {
byte decrypted[128];
uint16_t decLen = aesLib.decrypt(
ciphertext, msgLen, decrypted,
aes_key, sizeof(aes_key), iv
);
decrypted[decLen] = 0;
return String((char*)decrypted);
}Why/How: This function decrypts using AES-CBC. The IV is passed in (a copy), since AESLib modifies it. PKCS7 unpadding is applied automatically. The result is null-terminated and returned as a String.
4.6 MQTT Callback
void callback(char* topic, byte* payload, unsigned int length) {
Serial.print("[arrived] ");
Serial.print(length);
Serial.println(" bytes");
// …
}Tip: This callback is triggered whenever a message arrives on the subscribed topic.
Base64 decoding:
byte decoded[128];
int decodedLength = base64_decode_chars((char*)payload, length, (char*)decoded);Why/How: Base64 decodes the MQTT payload into raw bytes. The output is stored in decoded.
Message validation:
if (decodedLength < 32 || (decodedLength - 16) % 16 != 0) {
Serial.println("Invalid message length");
return;
}Note: Checks — At least 16 bytes (IV) + 16 bytes (ciphertext); Ciphertext must be multiple of 16 (AES block size).
byte iv[16]; memcpy(iv, decoded, 16);
uint16_t ciphertextLen = decodedLength - 16;
byte ciphertext[ciphertextLen];
memcpy(ciphertext, decoded + 16, ciphertextLen);Why/How
Get Mohamed Amine Barhoumi’s stories in your inbox
Join Medium for free to get updates from this writer.
Splits the decoded data: first 16 bytes = IV, rest = ciphertext.
byte iv_copy[16]; memcpy(iv_copy, iv, 16);
String decrypted = decrypt_message(ciphertext, ciphertextLen, iv_copy);Tip
AESLib modifies the IV during decryption, so we pass a copy.
4.7 Reconnect Loop
void reconnect() {
while (!client.connected()) {
if (client.connect("ESP32Sub")) {
client.subscribe(topic);
} else {
delay(5000);
}
}
}Why/How
Ensures persistent connection. On failure, retries every 5 seconds. Consider using a unique client ID in production.
4.8 Setup and Loop
void setup() {
Serial.begin(115200);
WiFi.begin(ssid, pass);
while (WiFi.status() != WL_CONNECTED) delay(500);
client.setServer(mqtt_server, 1883);
client.setCallback(callback);
aesLib.set_paddingmode((paddingMode)1); // PKCS7
}Note
(paddingMode)1 sets PKCS7 padding, which is required for CBC mode. Without it, decryption fails on non-block-sized messages.
void loop() {
if (!client.connected()) reconnect();
client.loop();
}Why/How
client.loop() must be called continuously to process incoming messages and maintain the connection.
Chapter 5
Keys, IVs, Padding, and Security
5.1 Keys
• AES-128 (16B): Fast and secure for most embedded uses.
• AES-192/256: Higher security margin but slower.
• Key Management: Never hardcode in production. Use secure elements or key derivation.
5.2 IVs
• Random and unique per message.
• Not secret — can be sent with ciphertext.
• IV reuse with same key compromises security.
5.3 Padding
- PKCS7: Adds 1–16 bytes so data fits AES blocks.
- CTR mode: Stream cipher — no padding needed.
- GCM: Authenticated encryption — includes integrity.
Chapter 6
ESP32 AES Publisher Code
This chapter complements the subscriber example by showing the full publisher code. It demonstrates how to generate a message, encrypt it using AES-CBC with a random IV, encode it in Base64, and publish it over MQTT.
6.1 Libraries
#include <WiFi.h>
#include <PubSubClient.h>
#include <AESLib.h>
#include <base64.h> // Arduino base64 libraryWhy/How
Why these? WiFi.h connects the ESP32 to the network, PubSubClient.h handles MQTT, AESLib.h encrypts messages, base64.h encodes the binary payload to text for MQTT.
6.2 Configuration and Buffers
const char* ssid = "ooredoo-6CCC29–5G";
const char* pass = "79B421EFWm35";
const char* mqtt_server = "broker.emqx.io";
const char* topic = "esp32/pub";
WiFiClient espClient;
PubSubClient client(espClient);
AESLib aesLib;
byte aes_key[] = { /* same 16-byte key as subscriber */ };
byte aes_iv[16];
byte ciphertext[128];Notes: The key must match the subscriber. IV and ciphertext buffers hold per-message encryption data.
6.3 Setup Function
void setup() {
Serial.begin(115200);
setup_wifi();
client.setServer(mqtt_server, 1883);
aesLib.set_paddingmode(PKCS7); // PKCS7 padding for AES-CBC
randomSeed(analogRead(0)); // Seed RNG for IV generation
}Tip
randomSeed(analogRead(0)) uses analog noise (floating pin) to generate unpredictable IVs.
6.3.1 PKCS7 Padding Explained
aesLib.set_paddingmode((paddingMode)1); // PKCS7 padding
Why/How
AES is a block cipher with a fixed block size (16 bytes). PKCS7 padding ensures that every plaintext fits exactly into a multiple of 16 bytes.
How it works:
- Suppose the plaintext is shorter than a multiple of 16 bytes.
- Count how many bytes are needed to reach 16-byte boundary (let’s say n bytes).
- Append n bytes, each with the value n.
— Example: plaintext = “HELLO” (5 bytes) → need 11 bytes padding → append 0x0B eleven times - If plaintext is exactly 16 bytes, append a full block of padding: 16 bytes of value 0x10.
- After AES-CBC encryption, the padding is hidden inside the ciphertext.
Decryption:
- The subscriber decrypts the ciphertext using AES-CBC.
- AESLib knows the padding mode is PKCS7.
- It automatically removes the last n bytes based on the PKCS7 scheme.
- Result: original plaintext without padding is recovered.
Tip
Key point: The padding is never visible in the transmitted ciphertext. Both publisher and subscriber must agree on using PKCS7, otherwise decryption fails or returns garbage.
6.4 Main Loop Explained
The loop() function continuously runs, ensuring the device stays connected, generates messages, encrypts them, and publishes securely.
void loop() {
if (!client.connected()) reconnect(); // Ensure MQTT is connected
client.loop(); // Process MQTT packets
String message = "Hello from publisher at " + String(millis());
// Generate random IV
for (int i = 0; i < 16; i++) {
aes_iv[i] = random(0, 256);
}
byte iv_copy[16];
memcpy(iv_copy, aes_iv, 16); // AES modifies IV internally
uint16_t encLen = aesLib.encrypt(
(byte*)message.c_str(),
message.length(),
ciphertext,
aes_key,
sizeof(aes_key),
iv_copy
);
byte payload[16 + encLen];
memcpy(payload, aes_iv, 16); // Prepend IV
memcpy(payload + 16, ciphertext, encLen); // Append ciphertext
String encoded = base64::encode(payload, sizeof(payload));
client.publish(topic, encoded.c_str());
delay(5000); // Wait 5s before next message
}Step-by-Step Explanation
- Connection check: if (!client.connected()) reconnect(); ensures MQTT stays connected.
- MQTT loop handler: client.loop(); processes incoming packets and keeps connection alive.
- Message generation: String message = “Hello …” + millis(); creates a unique message every iteration.
- Random IV creation: for (int i=0; i<16; i++) aes_iv[i] = random(0,256); creates a fresh 16-byte IV for AES-CBC.
- IV copy for encryption: memcpy(iv_copy, aes_iv, 16); preserves the original IV, because AES-CBC modifies it.
- AES encryption: aesLib.encrypt(…); encrypts the message with the shared key + IV.
- Payload construction:
— Prepend the IV: memcpy(payload, aes_iv, 16);
— Append ciphertext: memcpy(payload+16, ciphertext, encLen); - Base64 encoding: String encoded = base64::encode(payload,…); converts binary payload to text.
- MQTT publish: client.publish(topic, encoded.c_str()); sends encrypted message to the broker.
- Delay: delay(5000); pauses 5 seconds before the next iteration.
Why/How
Each cycle: Check connection → Generate message → Encrypt → Prepend IV → Encode → Publish → Wait.
Chapter 7
Extra: Generating AES Keys on the ESP32
Sometimes you may want the ESP32 to generate its own AES key rather than using a pre-defined one. This ensures randomness and uniqueness per device. The ESP32 has a hardware random number generator that we can use.
Example: Generate a 16-byte AES-128 Key
Listing 7.1: Generate AES Key on ESP32
#include <Arduino.h>
byte aes_key[16];
void setup() {
Serial.begin(115200);
// Fill aes_key with random bytes
for (int i = 0; i < 16; i++) {
aes_key[i] = (byte)(esp_random() & 0xFF); // only keep 1 byte
}
// Print key in 0x.. format
Serial.println("AES Key:");
for (int i = 0; i < 16; i++) {
Serial.print("0x");
if (aes_key[i] < 0x10) Serial.print("0");
Serial.print(aes_key[i], HEX);
if (i < 15) Serial.print(", ");
}
Serial.println();
}
void loop() {
}Why/How
What it does:
• Uses esp_random() to generate a 32-bit random number.
• Keeps only the lowest byte (0xFF mask) for each of the 16 bytes.
• Prints the key in {0x..} hexadecimal format suitable for Arduino sketches.
Notes:
• This key is random at each reset.
• Both publisher and subscriber must use the same key for AES encryption/decryption.
• AES-128 uses 16 bytes; you can also generate 24 or 32 bytes for AES-192 or AES-256.
Tip: You can generate the key once, print it, and then copy it into your subscriber sketch to use. This ensures both sides have the same key while keeping the initial generation secure.
You can also use this key too, recently generated:
0xD8, 0x45, 0x21, 0x0D, 0xE4, 0x59, 0x65, 0xE9, 0x26, 0x86, 0x8B, 0x55, 0xDE, 0xDB, 0xA0, 0xE7
Chapter 8
MQTT Details and Options
• Broker: broker.emqx.io:1883 — free public broker.
• QoS: 0 (fire-and-forget), 1 (at least once), 2 (exactly once).
• Client ID: Should be unique. Use ESP.getChipId().
• TLS: Use WiFiClientSecure and port 8883 for encryption.
• Keepalive: Default 15s — broker disconnects idle clients.







