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 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:
- Fire up a tunnel tool.
- Get hit with an unmemorable generated URL or an account upgrade paywall.
- Test your webhook.
- 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
rin 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]
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:
- When your CLI (
eta 3000) establishes its control WebSocket with the server, it sends ahellopacket containing an ephemeral ECDH public key (using the NIST P-256 /prime256v1curve). - The server responds with its own ephemeral public key inside a
readyframe. - Both sides perform Elliptic Curve Diffie-Hellman to compute a shared secret, and derive a 256-bit symmetric key using HKDF-SHA256.
- 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..."
}
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);
},
};
}
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
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
...
Tracking this down took hours of packet tracing. It boiled down to two subtle bugs:
Lost
thisin Fastify's WebSocket Handler:
Fastify’s@fastify/websocketcalls route handlers withwsHandler.call(this, socket, request)wherethisis bound to the Fastify instance instead of my router class. A standard class methodrouteWs(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.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$/;
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
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":"..."}
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"
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
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"
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 (
1000vs1006vs1008) - 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!
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.