DEV Community

Cover image for I Built an Encrypted Ngrok Alternative — The Hardest Part Wasn't the Crypto
explita
explita

Posted on Originally published at dev.to

I Built an Encrypted Ngrok Alternative — The Hardest Part Wasn't the Crypto

Like pretty much every developer who has ever tested a Stripe webhook, debugged OAuth callbacks, or demoed a local Next.js app to a teammate, I’ve spent years relying on tunneling tools.

And for years, the ritual looked like this:

  1. Fire up a tunnel tool.
  2. Get hit with an unmemorable generated URL or an account upgrade paywall.
  3. Test your webhook.
  4. Realize the tunnel relay in the cloud is sitting in the middle of your connection, terminating TLS, and technically has cleartext access to every authorization header, test credit card, and secret cookie crossing the wire.

Eventually, frustration turned into curiosity: What would it take to build a modern, high-throughput reverse tunnel that treats the relay server as untrusted by default?

Not just a generic port forwarder, but something that gives you:

  • Inner session encryption (AES-256-GCM) so the relay server cannot read your payloads.
  • Flawless streaming & WebSocket support (Next.js Turbopack HMR, Vite, Server-Sent Events).
  • A zero-dependency local inspector & instant replay (press r in the terminal to replay the last webhook without re-triggering it from Stripe).
  • Built-in IP/CIDR firewalls right at the edge.

Here is the story of building Explita Tunnel (eta), the architecture under the hood, and the hair-pulling edge cases I hit along the way.


Where Does This Fit? (Ngrok, Cloudflare, Tailscale)

Before getting into the crypto and networking guts, it’s worth addressing the elephant in the room: why not just use an existing tool?

  • Ngrok: Still the gold standard for developer ergonomics, but recent years brought pricing changes, random URLs on free tiers, browser interstitial warning pages, and closed-source relays that terminate TLS in cleartext.
  • Cloudflare Tunnel (cloudflared): Incredible for production services running on your own domain, but it requires routing your domain through Cloudflare DNS, configuring credentials, and managing daemon configs. It’s heavy when all you want is a 5-second throwaway URL to test a webhook.
  • Tailscale Funnel: Superb if your nodes already live on a Tailnet, but it requires Tailscale client routing and isn’t geared as a friction-free public webhook ingress for 3rd parties like Stripe or GitHub.
Tool Setup Friction Relay Trust Model Best For
Ngrok Minimal (CLI binary) Cleartext relay (TLS terminated at edge) Rapid prototyping & client demos
Cloudflare Tunnel High (Domain on Cloudflare DNS + config) Cleartext relay (TLS terminated at edge) Persistent production services on custom domains
Tailscale Funnel Moderate (Tailnet device auth) Cleartext relay (Tailnet ingress node) Private internal team mesh & node access
Explita Tunnel Minimal (CLI binary, zero config) Untrusted relay (Inner AES-256-GCM session) Webhook testing, HMR dev, & privacy-conscious local work

To be clear about trade-offs: if you're deploying a high-availability production service running 24/7 on a permanent company domain, tools like Cloudflare Tunnel or an AWS ALB are still the standard choice. Explita Tunnel is purpose-engineered for the active development loop: instantaneous throwaway ingress, local webhook testing, HMR stability, and inner payload confidentiality without account friction.

I wanted something with the 2-second speed of Ngrok, but with zero configuration, automatic subdomains, and an architecture where the relay server is treated as an untrusted pipe.


The Trust Problem with Traditional Tunnels

Most tunneling setups work like standard reverse proxies:

[Browser / Webhook] ---> (HTTPS/TLS) ---> [Tunnel Cloud Server] ---> (WebSocket/TCP) ---> [Your CLI Agent] ---> [Localhost:3000]
Enter fullscreen mode Exit fullscreen mode

Notice what happens at the Tunnel Cloud Server: TLS terminates there.

If the tunnel server is compromised, or if you're using a shared multi-tenant cluster, whoever operates that gateway has full visibility into your raw plaintext HTTP headers, authorization bearer tokens, customer webhook payloads, and environment secrets.

To fix this, I implemented an Untrusted Relay Architecture.


1. Untrusted Relay Architecture (ECDH + AES-256-GCM)

I designed the wire protocol so that even if an attacker completely controls the tunnel server, they see nothing but encrypted noise.

Here’s how the handshake works:

  1. When your CLI (eta 3000) establishes its control WebSocket with the server, it sends a hello packet containing an ephemeral ECDH public key (using the NIST P-256 / prime256v1 curve).
  2. The server responds with its own ephemeral public key inside a ready frame.
  3. Both sides perform Elliptic Curve Diffie-Hellman to compute a shared secret, and derive a 256-bit symmetric key using HKDF-SHA256.
  4. Every subsequent frame—incoming HTTP requests, response chunks, headers, and WebSocket data—is enveloped into an authenticated AES-256-GCM ciphertext:
{
  "type": "encrypted",
  "iv": "0H/rhkojjBR1sEgL",
  "tag": "2DAsewK7mcu2/EWxbs...",
  "data": "lZczO9B..."
}
Enter fullscreen mode Exit fullscreen mode

Here’s the actual Node.js crypto derivation under the hood:

import { createECDH, hkdfSync } from "node:crypto";

export function generateEcdhKeyPair() {
  const ecdh = createECDH("prime256v1");
  ecdh.generateKeys();
  const publicKey = ecdh.getPublicKey("base64");

  return {
    publicKey,
    computeSharedKey: (peerPublicKey: string): Buffer => {
      const rawSecret = ecdh.computeSecret(peerPublicKey, "base64");
      // Derive a 256-bit (32-byte) symmetric key via HKDF
      const derived = hkdfSync(
        "sha256",
        rawSecret,
        "",
        "explita-tunnel-e2ee",
        32,
      );
      return Buffer.from(derived);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

Session Fingerprints & Verifying the Handshake

To confirm that the key exchange succeeded and give each connection a unique cryptographic identity, the agent computes and displays a truncated SHA-256 session fingerprint:

✓ Key exchange complete • ECDH P-256 + AES-GCM (256-bit)
Fingerprint: SHA256:2c:2a:07:7b:a7:c4:7d:71:a1:fc:5a:99:85:69:18:67
Enter fullscreen mode Exit fullscreen mode

This fingerprint deterministically binds the client's public key, the server's public key, and the derived session key. It gives you immediate visual confirmation that the tunnel is running with inner encryption active rather than in plaintext. Whenever your connection reconnects or rotates, a fresh keypair is negotiated and the fingerprint changes.

(Looking ahead: because ephemeral ECDH alone does not authenticate the server's identity, I plan to add server-identity key signing in a future release so the agent can cryptographically verify the gateway out-of-the-box).

And what about performance? On modern hardware with AES-NI instructions, AES-256-GCM throughput impact is negligible (<1-2% CPU). The --no-encrypt flag remains available for low-power embedded microcontrollers or local testing when you want to eliminate cryptographic compute completely.

What Happens on Reconnect & Key Rotation?

If your laptop sleeps or your Wi-Fi drops, the client enters an exponential backoff reconnect loop. The server reserves your subdomain with a 60-second grace lock so nobody snatches it while you're offline.

Upon reconnecting, the agent and server negotiate a brand-new ephemeral ECDH keypair, rotating the session key automatically. In-flight requests during the drop fail cleanly, and subsequent traffic immediately resumes over the fresh cipher.


2. The Nightmare of Streaming & WebSockets (Gotchas from the Trenches)

Building basic request-response forwarding is easy. Anyone can write a 50-line Node.js prototype that fetches a URL and sends it over a socket.

What's hard is real-world web traffic.

The Next.js / Vite HMR Problem

When you run a modern frontend framework through a tunnel, it opens a persistent WebSocket connection for Hot Module Replacement (e.g. /_next/hmr).

During early testing with Next.js 15 + Turbopack, my browser console kept spamming:

[HMR] connected
[HMR] disconnected
[HMR] connected
...
Enter fullscreen mode Exit fullscreen mode

Tracking this down took hours of packet tracing. It boiled down to two subtle bugs:

  1. Lost this in Fastify's WebSocket Handler:
    Fastify’s @fastify/websocket calls route handlers with wsHandler.call(this, socket, request) where this is bound to the Fastify instance instead of my router class. A standard class method routeWs(socket, req) lost its scope, crashed on a property lookup, and triggered an abnormal closure (1006), kicking off an endless reconnect loop in the browser. Changing it to an arrow property (routeWs = (socket, req) =>) pinned the class context and fixed it instantly.

  2. Server-Sent Events (SSE) vs Compression:
    When you enable Brotli/Gzip compression on your edge proxy with @fastify/compress, it loves to buffer text streams until it hits a threshold (usually 1KB). But Server-Sent Events (text/event-stream) require immediate flushes. If your compression middleware buffers chunks, your live stream hangs. I tuned the compression regex to explicitly bypass SSE streams:

   // Compress text and JSON, but exclude streaming event-streams
   customTypes: /^text\/(?!event-stream)|\+json$|\+xml$/;
Enter fullscreen mode Exit fullscreen mode

Once those were solved, full-duplex WebSockets and HMR ran buttery smooth with zero drops.


3. Developer Experience: Instant Replay & Live Wire

Most CLI tools either show you nothing (just a static URL) or dump an overwhelming firehose of debug logs into your terminal.

I wanted a better middle ground:

1-Key Request Replay (r)

Ever spent 10 minutes setting up a checkout in Stripe Test Mode just to trigger a single webhook event to your local app?

When your local code throws a 500 error because of a typo, you normally have to go back to Stripe and trigger the event again.

With Explita Tunnel, you don't. Just hit r in your terminal.

⚡ Replaying: POST /api/webhooks/stripe...
200 OK POST /api/webhooks/stripe 14ms
Enter fullscreen mode Exit fullscreen mode

The agent stores recent requests in an in-memory ring buffer and replays the exact headers and payload directly against your local port without making round-trips to the internet.

Security note on replay: Because webhooks carry sensitive authentication tokens (like Stripe-Signature), the ring buffer is stored strictly in volatile RAM (never written to disk), capped at 50 requests (with a 2MB payload ceiling), and wiped from memory the moment the process terminates.

Live Wire Inspector (w)

Instead of flooding the console with raw frames by default, the terminal stays clean. But whenever you want to see what's happening on the wire, just tap w to toggle the live wire inspector:

[WIRE <-] 🔒 CIPHERTEXT {"type":"encrypted","iv":"...","tag":"...","data":"..."}
[WIRE ->] 🔒 CIPHERTEXT {"type":"encrypted","iv":"...","tag":"...","data":"..."}
Enter fullscreen mode Exit fullscreen mode

Tap w again, and you're back to the clean request stream.

Pro-tip I learned: When implementing a wire inspector, filter out internal keepalive ping and pong frames! Heartbeats tick every 15 seconds; nothing ruins a debug session faster than keepalive noise drowning out the API payload you were trying to read.

Built-in Local Web Dashboard

If you prefer a browser UI, the agent spins up a local inspector on http://127.0.0.1:39755 with live SSE updates, payload formatting, header inspection, and image previews. No external SaaS dashboard required.


4. Edge Security: IP & CIDR Firewalls

When you expose a local server to the internet, it’s exposed to everyone. Bots and port scanners will find it within minutes.

Instead of writing custom middleware in your local development server, you can tell the tunnel agent to enforce an IP allowlist right at the gateway:

eta 3000 --allow-ip "102.89.42.10, 192.168.1.0/24"
Enter fullscreen mode Exit fullscreen mode

Any unauthorized caller is rejected at the edge with an HTTP 403 Forbidden before the request ever touches your machine.


5. Trying It Out

Explita Tunnel (eta) is currently in early release.

You can install the CLI globally:

npm install -g @explita/tunnel
# or with pnpm
pnpm add -g @explita/tunnel
Enter fullscreen mode Exit fullscreen mode

Expose your local app:

# Expose port 3000
eta 3000

# Expose with a custom subdomain, auth, and IP restriction
eta 3000 -s myapp -a "dev:secret" -i "1.2.3.4"
Enter fullscreen mode Exit fullscreen mode

You can learn more and get access at:

👉 Website & Documentation: https://tunnel.explita.ng


What I Learned

Building networking tools from scratch is humbling. You think the hard part is cryptography or concurrency, but in reality, the hardest parts are the edge cases:

  • Socket close codes (1000 vs 1006 vs 1008)
  • Chunk encoding across binary and utf-8 streams
  • Keeping terminal interfaces responsive in raw TTY mode
  • Ensuring your relay never buffers what should be streaming

If you’re building developer tools or experimenting with WebSockets and cryptography in Node.js, I hope this breakdown gives you some useful ideas for your own architecture. And if you have any questions or feedback, let's chat in the comments!

Top comments (0)