To add password to Telnet on ESP32, you must implement a TCP-level authentication handshake before bridging the WiFiClient to the hardware Serial stream. Native Telnet lacks built-in authentication in basic Arduino wrappers, meaning anyone on your local network can read your serial logs or inject commands. By intercepting the initial connection, prompting for credentials, and filtering out Telnet IAC (Interpret As Command) negotiation bytes, you can secure your debug shell without relying on abandoned third-party libraries.

Build Profile:
Difficulty: Intermediate (Requires understanding of TCP streams and hex byte filtering)
Time to Complete: 30 minutes
Target Board: ESP32-WROOM-32 (DevKit V1, 30-pin)
Core Version: ESP32 Arduino Core v2.0.14 or newer

Parts List and Board Specifications

This build relies on the standard Espressif DevKit V1. Avoid the 38-pin variants if your breadboard is narrow, as they bridge across the center divider and leave no room for jumper wires.

ComponentExact Variant / SpecificationEstimated Cost (2026)
MicrocontrollerESP32-WROOM-32 DevKit V1 (30-pin, Type-C or Micro-USB)$5.50 - $7.00
Power/Data CableUSB 2.0 Data Cable (Must support data, not just charge)$4.00
Network2.4GHz 802.11 b/g/n Router (ESP32 does not support 5GHz)N/A
Terminal ClientPuTTY (Windows) or native telnet / nc (Linux/macOS)Free

Pin Mapping and Hardware Setup

Because Telnet operates entirely over the Wi-Fi radio, physical pin mapping is minimal. However, we map the onboard LED and the primary UART pins to provide visual feedback and hardware serial fallback.

FunctionESP32 GPIONotes
Onboard Status LEDGPIO 2Active HIGH on standard DevKit V1 boards.
Hardware Serial TXGPIO 1Connected to onboard CP2102/CH340 USB bridge.
Hardware Serial RXGPIO 3Used for local fallback debugging if Wi-Fi drops.

For physical setup, simply plug the ESP32 into your breadboard and connect the USB cable to your PC for initial flashing. Once flashed, the device can run off any 5V USB power supply.

The Complete Compilable Code

The following code targets the ESP32 DevKit V1. It establishes a Wi-Fi connection, opens a TCP server on port 23, and enforces a plain-text login. Crucially, it includes an IAC (0xFF) byte filter. According to the IETF RFC 854 Telnet Protocol Specification, clients like PuTTY will send negotiation hex bytes immediately upon connection. If you do not strip these, your password string will be corrupted by invisible control characters.

#include <WiFi.h>

// --- Pin Definitions ---
#define LED_PIN 2

// --- Network & Auth Credentials ---
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
const char* telnet_user = "admin";
const char* telnet_pass = "esp32debug";

// --- Server Configuration ---
WiFiServer telnetServer(23);
WiFiClient telnetClient;
bool authenticated = false;
bool iac_pending = false;

void setup() {
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);
  
  Serial.begin(115200);
  while(!Serial) { delay(10); }
  
  Serial.print("Connecting to ");
  Serial.println(ssid);
  WiFi.begin(ssid, password);
  
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  
  Serial.println("\nWi-Fi connected.");
  Serial.print("IP address: ");
  Serial.println(WiFi.localIP());
  
  telnetServer.begin();
  telnetServer.setNoDelay(true);
  Serial.println("Telnet server listening on port 23...");
}

void loop() {
  // Check for new incoming connections
  if (telnetServer.hasClient()) {
    if (telnetClient && telnetClient.connected()) {
      // Reject new connection if one is already active
      WiFiClient reject = telnetServer.available();
      reject.println("500 Max connections reached. Disconnecting.");
      reject.stop();
    } else {
      telnetClient = telnetServer.available();
      authenticated = false;
      telnetClient.println("ESP32 Secure Telnet Debug Shell");
      telnetClient.print("Username: ");
    }
  }

  // Handle active client data
  if (telnetClient && telnetClient.connected()) {
    while (telnetClient.available()) {
      uint8_t c = telnetClient.read();
      
      // Filter Telnet IAC (Interpret As Command) sequences to prevent auth corruption
      if (c == 0xFF) { iac_pending = true; continue; }
      if (iac_pending) { iac_pending = false; continue; } // Skip command and option bytes

      // Handle Authentication Phase
      if (!authenticated) {
        handleAuth(c);
      } 
      // Handle Bridged Serial Phase
      else {
        if (c == '\r' || c == '\n') {
          Serial.println();
          telnetClient.println();
        } else {
          Serial.write(c);
          telnetClient.write(c); // Local echo
        }
      }
    }
    
    // Bridge Hardware Serial to Telnet
    while (Serial.available()) {
      telnetClient.write(Serial.read());
    }
  } else {
    // Prevent WDT resets when no client is connected
    yield(); 
  }
}

void handleAuth(uint8_t c) {
  static String inputBuffer = "";
  static bool expectingPass = false;
  
  if (c == '\r' || c == '\n') {
    telnetClient.println();
    if (!expectingPass) {
      if (inputBuffer == telnet_user) {
        expectingPass = true;
        inputBuffer = "";
        telnetClient.print("Password: ");
      } else {
        telnetClient.println("Invalid username.");
        telnetClient.stop();
      }
    } else {
      if (inputBuffer == telnet_pass) {
        authenticated = true;
        expectingPass = false;
        inputBuffer = "";
        digitalWrite(LED_PIN, HIGH);
        telnetClient.println("\nAuthenticated. Bridging to Serial...");
        Serial.println("Telnet client authenticated.");
      } else {
        telnetClient.println("Invalid password.");
        telnetClient.stop();
      }
    }
  } else if (c == 0x08 || c == 0x7F) { // Backspace handling
    if (inputBuffer.length() > 0) {
      inputBuffer.remove(inputBuffer.length() - 1);
      telnetClient.print("\b \b");
    }
  } else {
    inputBuffer += (char)c;
    if (!expectingPass) {
      telnetClient.write(c); // Echo username
    } else {
      telnetClient.print("*"); // Mask password
    }
  }
}

Troubleshooting: First Three Things to Check When It Fails

When working with raw TCP streams on the ESP32, the Espressif Wi-Fi Driver can behave unpredictably if buffers aren't managed. If your board resets or refuses connections, check these three things first.

1. Exact Error: Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1)

  • Cause A (Most Likely): You have a blocking while(!telnetClient.available()) loop in your code without a yield() or vTaskDelay(1). The FreeRTOS Watchdog Timer (WDT) kills the task if it hogs the CPU for more than ~1.2 seconds.
  • Cause B: The Wi-Fi radio is experiencing a brownout during TX spikes. Ensure your USB power supply can deliver at least 500mA. Measure the 5V pin with a multimeter; if it dips below 4.6V during connection, add a 470µF electrolytic capacitor across the 5V and GND pins.

2. The Terminal Displays Gibberish Before the Password Prompt

  • Cause: Your terminal client (like PuTTY or TeraTerm) is sending Telnet Option Negotiation commands (e.g., 0xFF 0xFB 0x18 for Terminal Type). If you do not implement the IAC filter included in the code above, these hex bytes are read as literal text, corrupting your inputBuffer.
  • Fix: Ensure the if (c == 0xFF) { iac_pending = true; continue; } logic is present in your read loop.

3. Connection Refused or Immediate Disconnect on Port 23

  • Cause A: Your PC's firewall is blocking outbound TCP connections on port 23. Windows Defender Firewall frequently blocks raw Telnet.
  • Cause B: The ESP32 hasn't finished DHCP negotiation before the server starts. Always verify WiFi.status() == WL_CONNECTED before calling telnetServer.begin().

How to Extend or Simplify the Build

To Simplify: If you are only working in a closed, isolated lab environment and don't care about authentication, delete the handleAuth() function entirely. Set authenticated = true; immediately upon telnetServer.hasClient() returning true. This reduces flash usage by roughly 2KB and removes the backspace/masking logic.

To Extend: Plain-text Telnet sends your password unencrypted over the air. For production IoT deployments, extend this build by integrating mbedTLS to wrap the WiFiClient in a TLS layer, effectively creating a rudimentary SSH server. Alternatively, implement a challenge-response hash (like CRAM-MD5) so the actual password is never transmitted in the TCP payload.

Frequently Asked Questions

Can I use SSH instead of Telnet for encrypted ESP32 debugging?

Yes, but it requires significantly more resources. The ESP32 has enough RAM to handle TLS/SSH handshakes using the libssh-esp32 or mbedTLS libraries, but it consumes roughly 40KB-60KB of SRAM just for the cryptographic buffers. If your project is already using heavy libraries (like ESPAsyncWebServer or Bluetooth LE), you will likely hit memory fragmentation limits. Telnet with a simple password remains the standard for low-overhead local debugging.

Why does PuTTY send weird characters before the password prompt?

PuTTY is strictly adhering to the Telnet protocol standard. Before sending your keystrokes, it attempts to negotiate terminal features (like window size, echo, and terminal type) using hex bytes starting with 0xFF (IAC). Our code explicitly traps and discards these negotiation bytes so they don't get appended to your "admin" username string.

How do I hide the password characters as I type them in the terminal?

The provided code handles this in the handleAuth() function. When the expectingPass boolean is true, the code intercepts the incoming character, appends it to the hidden inputBuffer, but sends an asterisk (*) back to the Telnet client for local echo. This mimics standard Linux terminal behavior without requiring special Telnet LINEMODE negotiations.