Web Development

Webhook Signatures and Tunnels: The Debug Session That Haunts Me Still

A

Adil Sher

Author

Nov 3, 2026
4 min read
3 views
Webhook Signatures and Tunnels: The Debug Session That Haunts Me Still

I spent three hours last month staring at a 400 error from Stripe. The payload looked pristine. My secret was correct. I was using ngrok to forward localhost to a public URL for testing. Everything should have worked. It didn't. That's when I realized I didn't actually understand how webhook signatures work, I just knew they were supposed to work.

This is exactly the kind of problem that feels like a black box until you crack it open. Once you do, you realize the issue wasn't with signatures at all. It was that I was treating the request body like a normal JSON object, when what the provider actually signed was the exact raw bytes. That distinction matters more than I initially thought.

How Signatures Actually Work (And Why I Got It Wrong)

Webhook signatures are fundamentally a promise: "these exact bytes came from us, in this exact order, at this exact time." Providers use HMAC to prove it, they hash the raw content with a shared secret and send the hash in a header. Your job is to rebuild that hash and compare.

The problem is that different providers sign different things. Stripe signs the raw body plus a timestamp. Slack signs the raw body plus a timestamp. GitHub signs just the raw body. Twilio goes rogue and signs the full URL plus sorted POST parameters. Each approach is defensible, but each approach also creates different failure modes when something modifies the request in transit.

I learned this the hard way because my Express middleware was parsing JSON globally before my webhook routes even saw the request. By the time I tried to verify the signature, I wasn't hashing what Stripe sent, I was hashing whatever JSON.stringify(req.body) produced, which could have different whitespace, key ordering, or even number formatting. A valid-looking object. An invalid signature.

The Raw Body Problem

Here's the thing that got me: in Express, middleware order is destiny. If you declare express.json() globally, it consumes the request stream first. When your webhook route later tries to use express.raw(), there's nothing left to read. The body is already parsed and serialized into an object.

I fixed this by using the verify hook in Express:

app.use(express.json({ 
 verify: (req, _res, buf) => { 
 req.rawBody = buf; 
 } 
}));

app.post('/webhooks/stripe', (req, res) => {
 const signature = req.headers['stripe-signature'];
 const secret = process.env.STRIPE_WEBHOOK_SECRET;
 
 // Use req.rawBody, NOT req.body
 try {
 const event = stripe.webhooks.constructEvent(req.rawBody, signature, secret);
 // handle event
 } catch (err) {
 return res.status(400).send(`Webhook Error: ${err.message}`);
 }
});

This preserves the exact bytes while still letting Express parse JSON for other routes. It feels like a workaround, but it's actually the correct pattern.

The Tunnel Makes It Worse

Here's where tunnels and proxies enter the picture. If something in the request path, a tunnel inspector, a middleware, a reverse proxy, modifies the body, the signature breaks. The request "looks healthy" but the bytes have changed.

The most insidious version: ngrok's Traffic Inspector doesn't capture full bodies by default, to avoid exposing secrets. But if you're trying to debug a signature mismatch, you need to see those bytes. I had to dig through ngrok settings to figure that out. There's no error message that tells you "hey, we modified your request because we're inspecting it."

Then there's timestamp validation. Stripe and others add a timestamp to the signature to prevent replay attacks, with a default tolerance of five minutes. A tunnel, clock skew, or slow local development can push past that window. It's another invisible failure mode.

What I'd Do Differently Now

The article covers three failure families, but from my perspective, the real lesson is this: webhook signature verification is fragile by design because it's supposed to be. You can't test it properly without actually sending data through the exact path it will take in production.

I'm skeptical of local development tunnels for webhook testing. They work, but they add variables. I prefer using a staging environment with real DNS and real networking, or using provider-specific test modes (Stripe has the CLI for a reason). If you must use a tunnel, understand exactly what it's doing to your request.

Also: capture the raw body at the router level, before any middleware runs. Make it a habit, not an afterthought.

My Question for You

Have you hit this? I want to know whether you caught it immediately or spent hours like I did. What's your approach to webhook testing in development?


Source: This post was inspired by "Signed, Sealed, Mangled: Why Webhook Signatures Fail Behind a Tunnel" by Dev.to. Read the original article

Share this article

Written by Adil Sher

Full stack developer building high-traffic platforms, AI services, and custom web applications. Explore my portfolio, learn about my background, or get in touch.

Related Articles

Why I'm Done Selling Clients Off-the-Shelf Themes
Web Development Nov 2

Why I'm Done Selling Clients Off-the-Shelf Themes

I had this conversation last month with a real estate agent in Rawalpindi. She wanted a website, found a "premium" template on ThemeForest, and asked if I could just install it. The template looked polished, smooth animations, modern design, the works. I said yes, and three weeks...

Why I'm Rethinking How We Handle AI in Production Apps
Web Development Nov 1

Why I'm Rethinking How We Handle AI in Production Apps

Last month, I was sitting in a standup meeting when someone asked how we'd handle our LLM API if the provider went down. The room went quiet. We'd built our entire feature around OpenAI's reliability, with barely a fallback in sight. That conversation stuck with me, mostly because...

Stop Pretending Your RAG System Knows What It Doesn't
Web Development Oct 31

Stop Pretending Your RAG System Knows What It Doesn't

Last month, a client asked me to build a "smart document search" for their internal knowledge base. I nodded, quoted them a timeline, and started architecting the usual suspects: vector embeddings, semantic search, a language model API call. Two weeks into development, during a d...