The most reliable way to achieve ESP32 simple text encryption without pulling in heavy external libraries is a rolling XOR cipher paired with hexadecimal encoding. While the ESP32 features a dedicated hardware cryptographic accelerator for AES, a software-based XOR stream cipher is vastly superior for low-latency serial debugging, basic obfuscation, and educational projects where minimizing memory footprint is critical.

In this guide, we will build a bidirectional encrypted serial bridge. Plain text typed into the serial monitor is encrypted and output as hex; hex strings prefixed with a tilde (~) are decrypted back to plain text. The code targets the ubiquitous ESP32-WROOM-32 (30-pin DevKit V1) and requires zero external dependencies.

Project Scope and Hardware BOM

Difficulty: Beginner-Intermediate | Time: 20 Minutes | Cost: <$6

Before writing code, verify your hardware. The ESP32's dual-core architecture and 520KB of SRAM make it overkill for a simple XOR cipher, but this project serves as a foundational template for secure IoT sensor payloads.

  • Microcontroller: ESP32-WROOM-32 DevKit V1 (30-pin variant). Note: The 38-pin ESP32-WROVER will also work, but pin mappings for onboard LEDs differ.
  • Connection: High-quality USB 2.0 Micro-B data cable (avoid charge-only cables, which cause silent serial drops).
  • Indicator: 5mm LED (any color) and a 220Ω through-hole resistor (optional, for external status indication if the onboard GPIO 2 LED is insufficient).
  • Software: Arduino IDE 2.x with the official Espressif ESP32 Board Package (v2.0.14 or newer recommended for stable mbedtls integration if you expand later).

Algorithm Showdown: Which Encryption Fits Your ESP32?

Choosing the right algorithm depends on your payload size and security requirements. Below is a data-dense comparison of common text encryption methods running natively on the ESP32-WROOM-32 clocked at 240MHz.

Algorithm Execution Time (per 1KB) RAM Overhead Security Level Library Dependency
Rolling XOR (This Build) ~18 µs 16 bytes (Key) Low (Obfuscation) None (Pure C++)
AES-128-CBC (Software) ~450 µs ~2.5 KB High AESLib / Crypto
AES-128-CBC (Hardware) ~35 µs ~1.2 KB High ESP-IDF mbedtls
ChaCha20 ~120 µs ~1.5 KB Very High Arduino CryptoLib
Bench Insight: If you are transmitting sensor data over LoRa or MQTT where every byte costs airtime or bandwidth, XOR encryption combined with a hardware-generated nonce is often sufficient to prevent casual packet sniffing, while keeping the payload size identical to the plaintext. For financial or PII data, you must use the hardware-accelerated AES-128 via Espressif's Crypto API.

Pin Mapping and Wiring

This project is primarily serial-based, but we map the onboard LED to provide visual feedback when the encryption buffer is actively processing data. This prevents "ghost" resets from looking like successful transmissions.

Component ESP32 Pin Direction Notes
Onboard Status LED GPIO 2 OUTPUT Active HIGH on most DevKit V1 clones.
External LED Anode GPIO 25 (Optional) OUTPUT Use if GPIO 2 is occupied by SDIO.
USB Serial TX/RX GPIO 1 / GPIO 3 I/O Routed through the CP2102/CH340 USB bridge.

The Code: Rolling XOR with Hex Encoding

The following C++ code is fully compilable in the Arduino IDE. It uses a 16-byte pre-shared key (PSK) to perform a rolling XOR operation. Because raw binary data cannot be safely printed to the Arduino Serial Monitor, the encrypted bytes are encoded into a hexadecimal string.

#include <Arduino.h>

// --- PIN DEFINITIONS ---
#define STATUS_LED 2

// --- CRYPTO CONFIGURATION ---
#define KEY_LENGTH 16
#define MAX_BUFFER 256

// 16-byte Pre-Shared Key (PSK). Change this to your own random bytes.
const uint8_t encryptionKey[KEY_LENGTH] = {
  0x3F, 0xA1, 0x2B, 0x9C, 0x4D, 0xE5, 0x77, 0x12,
  0x88, 0x6F, 0xC3, 0x5A, 0xD9, 0x04, 0xB7, 0x2E
};

char inputBuffer[MAX_BUFFER];
int bufferIndex = 0;

// Helper: Convert a single hex character to its integer value
uint8_t hexCharToByte(char c) {
  if (c >= '0' && c <= '9') return c - '0';
  if (c >= 'A' && c <= 'F') return c - 'A' + 10;
  if (c >= 'a' && c <= 'f') return c - 'a' + 10;
  return 0;
}

void setup() {
  Serial.begin(115200);
  pinMode(STATUS_LED, OUTPUT);
  
  // Prevent Serial.readStringUntil from blocking indefinitely
  Serial.setTimeout(5); 
  
  delay(1000);
  Serial.println("\n--- ESP32 Simple Text Encryption Bridge ---");
  Serial.println("Type plain text to encrypt.");
  Serial.println("Type ~ followed by HEX to decrypt (e.g., ~5A4B...)");
}

void loop() {
  while (Serial.available()) {
    char c = Serial.read();
    
    // Blink LED to indicate serial activity
    digitalWrite(STATUS_LED, HIGH);
    
    if (c == '\n' || c == '\r') {
      if (bufferIndex > 0) {
        inputBuffer[bufferIndex] = '\0'; // Null-terminate
        processInput(inputBuffer);
        bufferIndex = 0;
      }
    } else {
      // Prevent buffer overflow
      if (bufferIndex < MAX_BUFFER - 1) {
        inputBuffer[bufferIndex++] = c;
      } else {
        Serial.println("\n[ERROR] Buffer overflow. Input truncated.");
        bufferIndex = 0;
      }
    }
    digitalWrite(STATUS_LED, LOW);
  }
}

void processInput(char* data) {
  if (data[0] == '~') {
    // DECRYPT MODE: Hex string to Plain text
    decryptHexToText(data + 1);
  } else {
    // ENCRYPT MODE: Plain text to Hex string
    encryptTextToHex(data);
  }
}

void encryptTextToHex(const char* plaintext) {
  Serial.print("[TX ENCRYPTED]: ");
  int len = strlen(plaintext);
  for (int i = 0; i < len; i++) {
    uint8_t encryptedByte = plaintext[i] ^ encryptionKey[i % KEY_LENGTH];
    if (encryptedByte < 0x10) Serial.print('0'); // Pad single digit hex
    Serial.print(encryptedByte, HEX);
  }
  Serial.println();
}

void decryptHexToText(const char* hexString) {
  int len = strlen(hexString);
  if (len % 2 != 0) {
    Serial.println("[ERROR] Invalid hex length (must be even).");
    return;
  }
  
  Serial.print("[RX DECRYPTED]: ");
  for (int i = 0; i < len; i += 2) {
    uint8_t highNibble = hexCharToByte(hexString[i]);
    uint8_t lowNibble = hexCharToByte(hexString[i+1]);
    uint8_t encryptedByte = (highNibble << 4) | lowNibble;
    
    // XOR back to plaintext
    char decryptedChar = encryptedByte ^ encryptionKey[(i/2) % KEY_LENGTH];
    Serial.print(decryptedChar);
  }
  Serial.println();
}

Debugging: When the Cipher Breaks

Cryptographic implementations on microcontrollers frequently fail due to memory mismanagement rather than math errors. If your ESP32 resets or outputs garbage, review these exact failure modes.

Exact Error Strings and Ranked Causes

Error 1: Guru Meditation Error: Core 1 panic'ed (StackOverflow). exception was thrown: 0x3f400000

  • Cause A (Most Likely): You attempted to upgrade the code to AES and allocated a large encryption buffer (e.g., uint8_t cipher[2048]) locally inside the loop() function. The ESP32's task stack is typically 8KB; large local arrays smash the stack.
  • Fix: Move large buffers to the global scope, or allocate them on the heap using malloc() or the String class. The provided XOR code uses a safe 256-byte global buffer.

Error 2: fatal error: mbedtls/aes.h: No such file or directory

  • Cause A: You tried to copy an ESP-IDF native AES example into the Arduino IDE. The Arduino core wraps mbedtls differently, and direct header inclusion often fails depending on your core version.
  • Fix: Install the AESLib via the Arduino Library Manager, or use the pure C++ XOR implementation above which bypasses the need for crypto headers entirely.

The First Three Things to Check

  1. Serial Monitor Line Endings: If the ESP32 seems to ignore your input or prints empty brackets, your Arduino Serial Monitor is likely set to "No Line Ending". Change the dropdown in the bottom right corner to Both NL & CR. The code relies on \n or \r to trigger the encryption function.
  2. Baud Rate Mismatch: Ensure both the code (Serial.begin(115200)) and your Serial Monitor are set to 115200. A mismatch will result in high-entropy garbage that looks exactly like a broken cipher.
  3. Key Synchronization: If you are testing this between two separate ESP32 boards, verify that the encryptionKey array is byte-for-byte identical on both devices. A single flipped bit in the PSK will corrupt the entire decrypted stream.

Extending and Simplifying the Build

Depending on your end goal, you may need to scale this project up for production or down for a basic classroom demonstration.

How to Extend to Hardware AES

If you need to meet OWASP cryptographic storage standards for an IoT product, XOR is insufficient. To extend this build:

  1. Install the AESLib by intrbiz via the Library Manager.
  2. Replace the XOR loop with aes128_cbc_enc().
  3. Critical Step: AES requires padding (usually PKCS7) to fill the final 16-byte block. You must append padding bytes before encryption and strip them after decryption, which will increase your payload size by up to 16 bytes.

How to Simplify for Education

If you are teaching basic logic gates or introductory programming, you can simplify the rolling XOR to a static Caesar Cipher (shift cipher). Replace the XOR line in the code with:

char encryptedChar = plaintext[i] + 3; // Shift ASCII value by 3

Warning: While easier to explain to beginners, a Caesar shift leaks the exact frequency distribution of the English language, making it trivially crackable via statistical analysis. It should only be used for educational demonstrations, never for actual data protection.