A webhook 401 Unauthorized response means your endpoint rejected the request because the signature verification failed — the HMAC computed from the incoming payload does not match the value in the signature header. This is one of the most common webhook errors, and almost always comes down to one of three root causes.
Root Causes
1. Parsing the body before verifying the signature
HMAC verification requires the raw request bytes — exactly as sent by the provider. If your framework parses the JSON body before you read the raw bytes, the byte sequence changes (whitespace, key order, encoding) and the HMAC will never match. This is the most common cause of 401 errors on Stripe, GitHub, and Shopify webhooks.
// WRONG — body is already parsed, HMAC will fail
app.use(express.json())
app.post('/webhook', (req, res) => {
const sig = req.headers['stripe-signature']
stripe.webhooks.constructEvent(req.body, sig, secret) // fails
})
// CORRECT — use raw body for the webhook route only
app.post('/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature']
const event = stripe.webhooks.constructEvent(req.body, sig, secret) // works
res.json({ received: true })
}
)
2. Wrong webhook secret
Each webhook endpoint gets its own signing secret. If you rotate the secret in the provider dashboard but do not update your environment variable, every delivery will return 401. Check that WEBHOOK_SECRET in your deployment matches the current secret shown in the provider dashboard.
3. Charset or encoding mismatch
Some providers send the signature as hex, others as base64. Check the provider docs for the exact encoding used in the header.
Fix by Provider
| Provider | Signature Header | Algorithm | Key Issue |
|---|---|---|---|
| Stripe | Stripe-Signature | HMAC-SHA256 | Must use raw body; use stripe.webhooks.constructEvent() |
| GitHub | X-Hub-Signature-256 | HMAC-SHA256 | Compare hex digest, strip sha256= prefix |
| Shopify | X-Shopify-Hmac-Sha256 | HMAC-SHA256 | Header is base64-encoded; use raw body |
| Twilio | X-Twilio-Signature | HMAC-SHA1 | Sorts params alphabetically before hashing |
Node.js Fix (Express)
const crypto = require('crypto')
const express = require('express')
const app = express()
app.post('/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const secret = process.env.WEBHOOK_SECRET
const headerSig = req.headers['x-hub-signature-256'] || ''
const computed = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(req.body)
.digest('hex')
if (!crypto.timingSafeEqual(Buffer.from(headerSig), Buffer.from(computed))) {
return res.status(401).json({ error: 'Invalid signature' })
}
const event = JSON.parse(req.body)
res.json({ received: true })
}
)
Python Fix (FastAPI)
import hashlib, hmac, os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post('/webhook')
async def webhook(request: Request):
secret = os.environ['WEBHOOK_SECRET'].encode()
raw_body = await request.body()
header_sig = request.headers.get('x-hub-signature-256', '')
expected = 'sha256=' + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(header_sig, expected):
raise HTTPException(status_code=401, detail='Invalid signature')
return {'received': True}
FAQ
Why does HMAC fail only in production but work locally?
Your local server likely uses a different body parser configuration. Check whether a global express.json() middleware runs before your webhook route in production. Move the webhook route before any body-parsing middleware, or use express.raw() specifically for that route.
Can I test HMAC verification without a real provider?
Yes. WebhookWhisper lets you send test payloads with a custom HMAC signature using any secret. Set the secret in your handler, fire a test payload from the provider testing page, and verify the signature in your logs.
What is timingSafeEqual and why does it matter?
crypto.timingSafeEqual compares two buffers in constant time, preventing timing attacks where an attacker could guess the signature one byte at a time by measuring response latency. Always use it instead of === for signature comparison.