Local Webhook Development with Tunnels: Exposing localhost Safely

Iterating on a webhook handler is part of webhook testing and local development, and it begins with a structural problem: providers dispatch HTTP POST requests to a public URL, but the handler you are editing runs on localhost behind NAT and a firewall, unreachable from the public internet. A tunnel solves this by allocating a public hostname at an edge service and forwarding every inbound request down a persistent outbound connection to a local port. The provider sees a normal HTTPS endpoint; you keep your debugger, hot reload, and logs on your own machine. The discipline that matters is making the tunneled environment behave exactly like production — same signature check, same timestamp window, same rejection codes — so that code which works locally also works when it ships.

Provider to tunnel to localhost flow A provider sends an HTTPS POST to a tunnel edge, which forwards it down an outbound connection from the developer machine to a local port for verification. Provider signs payload Tunnel edge public HTTPS URL developer machine Tunnel agent outbound connection localhost:8000 verify + handle POST forward
A provider signs and POSTs to the tunnel's public HTTPS URL; the tunnel agent forwards the request down an outbound connection to the local port, where the raw body is verified.

Tunnel Patterns: Ephemeral vs Persistent Hostnames

Two implementation patterns dominate, and the choice has real consequences for how often you re-register with a provider.

Ephemeral tunnels. Running ngrok http 8000 allocates a random hostname for the session. This is the fastest path for a one-off experiment, but the URL changes on every restart, so you must re-register it with the provider each time — tedious when the provider’s dashboard rate-limits endpoint updates. Use ephemeral tunnels for throwaway debugging.

Persistent named tunnels. Both ngrok (reserved domains) and cloudflared (named tunnels bound to a DNS record) give a stable hostname that survives restarts. cloudflared tunnel run my-dev routes a subdomain you control to a local port indefinitely, so you register the URL with the provider once. This is the right default for any integration you will touch over more than a day, and it lets a team share a stable staging-like address.

A third consideration is body fidelity. A signature check hashes the exact bytes of the request body, so the tunnel must forward the raw stream untouched. Both ngrok and cloudflared do this by default, but a misconfigured intermediate proxy that re-encodes JSON or strips a header will break verification in ways that look like a cryptography bug. When a locally-running handler rejects a payload the provider considers valid, suspect the transport before the code.

Reproducing Signature Verification Locally

The point of local development is to exercise the real security path, not a bypassed one. Your handler must run the same HMAC signature verification against a development secret that production runs against the live secret, and it must enforce the same timestamp tolerance that underpins replay attack prevention. The development secret is a different value from production — never tunnel to a handler holding production secrets — but the verification logic is byte-identical.

# local_handler.py — identical verification path to production, dev secret only
import hmac, hashlib, time, os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
SECRET = os.environ["WEBHOOK_DEV_SECRET"].encode()   # NEVER the prod secret
TOLERANCE = int(os.environ.get("WEBHOOK_TOLERANCE_SEC", "300"))

@app.post("/webhook")
async def webhook(request: Request):
    raw = await request.body()                        # raw bytes the tunnel forwarded
    header = request.headers.get("x-signature", "")
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        ts, sig = int(parts["t"]), parts["v1"]
    except (KeyError, ValueError):
        raise HTTPException(status_code=401, detail="malformed signature header")

    if abs(time.time() - ts) > TOLERANCE:             # reject stale / replayed events
        raise HTTPException(status_code=403, detail="timestamp outside tolerance")

    expected = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        raise HTTPException(status_code=403, detail="signature mismatch")

    print(f"[ok] verified delivery, {len(raw)} bytes")  # your debugger lives here
    return {"status": "accepted"}

Environment Configuration & CI Hygiene

Local tunneling lives or dies on configuration discipline. Keep development secrets out of the repository and load them from a .env file that is git-ignored, with a committed .env.example documenting the required keys. Bind the tunnel to the same port your app listens on, and parameterize the tolerance window so tests can shrink it to assert that stale payloads are rejected.

# .env.example — committed; real .env is git-ignored
WEBHOOK_DEV_SECRET=replace-with-provider-test-secret
WEBHOOK_TOLERANCE_SEC=300
APP_PORT=8000

In CI you should not depend on a live tunnel — that introduces network flakiness and a third-party dependency into your pipeline. Instead, reserve tunnels for interactive local work and exercise the same handler in CI by posting locally-signed fixtures with a test client, which is exactly the deterministic approach used in webhook contract testing. The tunnel is for the human in the loop; CI runs the handler in-process.

Failure Modes & Diagnostics

Failure Mode Root Cause Mitigation
Signature mismatch only over the tunnel An intermediate proxy re-encoded the JSON body before forwarding Forward the raw byte stream; disable any body-rewriting in the tunnel config
Provider URL stops working after restart Ephemeral tunnel allocated a new random hostname Use a reserved domain (ngrok) or named tunnel (cloudflared) for a stable URL
Handler accepts unsigned payloads locally Verification disabled “for convenience” during development Always run the production verification path; use a dev secret, never skip the check
Replayed test event is accepted twice Timestamp tolerance too wide or no idempotency check Enforce the production tolerance window and an idempotency key on the handler
502 from the tunnel edge Local app not listening on the forwarded port Confirm the app port matches the tunnel target before registering the URL

Debugging Checklist

For the exact commands to install a tunnel, expose your port, register the URL, and replay a provider’s test events end to end, follow testing webhooks locally with ngrok and tunnels.