Two approaches, one question nobody asks early enough
Every webhook integration eventually hits the same fork in the road. You need to verify that incoming requests are actually from who they claim to be, and you've got two mainstream options: HMAC signature verification or API key authentication.
Most teams pick whichever one the first third-party provider they integrate with uses. Stripe does HMAC? Cool, we do HMAC. Some internal service passes an API key in a header? Good enough.
That works until it doesn't.
API keys: the comfortable lie
API keys are easy to understand. The sender includes a shared secret in a header, you check if it matches. Done. Five lines of middleware and you're "authenticated."
app.post('/webhooks/incoming', (req, res) => {
const token = req.headers['x-webhook-token']
if (token !== process.env.WEBHOOK_SECRET) {
// someone's knocking who shouldn't be
return res.status(401).json({ error: 'unauthorized' })
}
// process the event
handleEvent(req.body)
res.status(200).send('ok')
})
Simple. Fast. And it has a problem that most developers don't think about until they're staring at a post-mortem doc at 11 PM on a Friday.
The secret travels with every request. It's in the HTTP headers, sitting right there in plaintext (yes, TLS encrypts it in transit, but it's plaintext at rest in your logs, your reverse proxy configs, your APM tool). Anyone who intercepts or logs that header has permanent access to your endpoint. The secret doesn't change per request. It doesn't expire. It just... exists.
Rotate it? Sure. Now coordinate that rotation across every service that sends to this endpoint. At 3 AM. Without downtime.
HMAC signatures: the better tradeoff
HMAC takes a different approach. Instead of sending the secret, the sender uses it to compute a hash of the request body. You share the secret once during setup. After that, only the signature travels over the wire.
const crypto = require('crypto')
function verifyWebhookSignature(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex')
// timing-safe comparison, not ===
// you'd be surprised how many production systems use ===
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)
}
This is what Stripe, GitHub, Shopify, and most serious webhook providers use. There's a reason.
If someone captures the signature header, they can't reuse it. The signature is tied to that specific payload. Change one byte in the body and the signature is invalid. They'd need the shared secret to forge a new one, and the secret never left your server.
The timestamp problem nobody warns you about
Pure HMAC verification has a gap. If an attacker captures a legitimate request (headers, body, signature, everything), they can replay it. The signature is valid. The body hasn't changed. Your server processes it again.
This is why Stripe includes a timestamp in their signature scheme. The signed payload is actually timestamp.body, and you're expected to reject anything older than five minutes.
function verifyStripeStyle(payload, sigHeader, secret) {
const parts = sigHeader.split(',')
const timestamp = parts.find(p => p.startsWith('t='))?.split('=')[1]
const signature = parts.find(p => p.startsWith('v1='))?.split('=')[1]
if (!timestamp || !signature) return false
// reject requests older than 5 minutes
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp)
if (age > 300) return false
const signedPayload = timestamp + '.' + payload
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)
}
Teams that implement HMAC but skip the timestamp check have a false sense of security. Seen it happen at a fintech startup that thought signature verification alone was sufficient. They found duplicate charge events in their database three months later.
When API keys actually make sense
Not every webhook endpoint needs HMAC. Really.
Internal services communicating over a private network, behind a VPN, with mutual TLS? An API key in a header is probably fine. You've got network-level security doing the heavy lifting. Adding HMAC on top is defense in depth, which is great, but the cost-benefit math is different when you control both sides.
Prototyping and development environments are another case. You're iterating fast, changing payload formats daily. HMAC verification that breaks every time you modify the request body slows you down. Ship the API key version, test your business logic, add proper signing before production.
But if you're receiving webhooks from external providers over the public internet? HMAC. Every time. No exceptions.
The hybrid approach most people miss
Here's something that doesn't get discussed enough. You can use both.
API key for routing (which tenant does this webhook belong to?) and HMAC for verification (is this request authentic and untampered?). The API key tells you whose secret to look up for HMAC verification. Without it, you'd need to try every tenant's secret against the signature, which doesn't scale past a handful of integrations.
app.post('/webhooks/incoming', async (req, res) => {
const tenantKey = req.headers['x-tenant-id']
const signature = req.headers['x-webhook-signature']
// step 1: identify the tenant (fast lookup)
const tenant = await db.tenants.findByWebhookKey(tenantKey)
if (!tenant) return res.status(401).send('unknown tenant')
// step 2: verify authenticity with tenant-specific secret
const rawBody = req.rawBody // make sure your framework preserves this
if (!verifyHmac(rawBody, signature, tenant.webhookSecret)) {
return res.status(403).send('invalid signature')
}
// both checks passed
processEvent(tenant.id, req.body)
res.status(200).send('ok')
})
Slack does something similar with their signing secret approach. The app ID identifies which workspace, the signature proves it's actually from Slack.
Raw body parsing will bite you
Quick detour because this trips up so many people. HMAC is computed over the raw request body. The exact bytes that came over the wire. If your framework parses JSON before you get to compute the signature, you've lost the original string. JSON.parse and JSON.stringify don't guarantee the same byte sequence.
Express middleware ordering matters here:
// this breaks HMAC verification
app.use(express.json())
app.post('/webhooks', verifySignature, handleWebhook)
// this works
app.post('/webhooks',
express.raw({ type: 'application/json' }),
verifySignature,
handleWebhook
)
Spent two days debugging this once. The signature was "wrong" every time. Turned out bodyParser was silently re-serializing the JSON with different whitespace. Two days.
Key rotation: where theory meets production
Both approaches need secret rotation. The difference is how painful it gets.
API key rotation means coordinating a change between sender and receiver simultaneously. Miss the window and requests fail. Most teams handle this by supporting two active keys during a transition period, accepting either the old or new key for a few hours.
HMAC rotation follows the same pattern but with an added wrinkle. Some providers (Stripe, for instance) send multiple signatures in the same header, one computed with the old secret and one with the new. Your verification code should try both. If either matches, the request is valid.
function verifyWithRotation(payload, signatures, secrets) {
// secrets is an array: [currentSecret, previousSecret]
for (const secret of secrets) {
for (const sig of signatures) {
if (verifyHmac(payload, sig, secret)) return true
}
}
return false
}
If you're building a webhook sender, build this in from day one. You will rotate secrets. Make it painless before you're forced to do it during an incident.
What to actually pick
For receiving external webhooks: HMAC with timestamps. Use whatever scheme your provider implements (Stripe's v1 scheme, GitHub's sha256 header, Shopify's HMAC header). Don't invent your own.
For sending webhooks to your customers: HMAC with timestamps. Provide signing secret rotation. Document it clearly. Look at Svix or Standard Webhooks for a spec to follow so you're not reinventing the wheel.
For internal service-to-service over private networks: API keys are acceptable if you have network-level controls. Add HMAC if your threat model warrants it.
For prototypes: whatever gets you shipping. Just don't forget to circle back.
The authentication method you choose is one layer. Combine it with TLS, IP allowlisting where possible, idempotency handling, and monitoring. No single mechanism is the whole story, but getting the foundation right makes everything else easier.