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.
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
- Tunnel forwards the raw, unmodified request body
- Handler verifies signatures with a development secret, not the production one
- Timestamp tolerance matches production and rejects stale events
- Public URL is stable (reserved domain or named tunnel) for multi-day work
- Secrets load from a git-ignored
.env, with a committed.env.example - An idempotency key prevents double-processing of replayed test events
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.
Related
- Testing webhooks locally with ngrok and tunnels — the step-by-step command walkthrough.
- Webhook contract testing — deterministic, network-free payload assertions for CI.
- Inspecting and replaying webhook deliveries — capture and re-run failed deliveries.
- Webhook Testing & Local Development — the full testing pipeline.