Webhook Security
Replay prevention
| Complexity |
|
| Pros |
|
| Caveats |
|
| Examples |
Many webhook vendors use hashing and encryption to add security beyond authentication and message integrity. By combining message integrity with timestamps, providers offer a way to validate when calls are made and mitigate replay attacks. In our research, we saw the use of timestamps in conjunction with HMAC — i.e. Calendly, Stripe, and Slack, asymmetric signatures — PayPal, and JWT/JWK/OAuth — 8x8 and Plaid. Timestamp generation and validation takes the following steps:
On webhook requests, the provider:
- Concatenates the timestamp of the request creation with the webhook payload
- Signs the concatenated value
- Sends both the encoded signature and the timestamp to the webhook request
The webhook listener receives the request and:
Extracts the timestamp — i.e., from the request header — and validates if the timestamp is within an acceptable timeframe (i.e., 3-5 minutes) of the current time, in either direction.
... const timestampHeader = 'Request-Timestamp' app.post('/webhook', (req, res) => { // Request timestamp in unix date * 1000 (milliseconds) const requestTimestamp = req.get(timestampHeader) * 1000; // Tolerance zone: 5 minutes before or after now const tolerance = 5 * 60 * 1000; if (!requestTimestamp || Math.abs(Date.now() - requestTimestamp) > tolerance) { // The request timestamp is outside of the tolerance zone. res.status(403).send('Request expired') }else{ // The request timestamp is in the tolerance zone. ...If the timestamp is valid, repeat the same steps from the webhook provider to sign the request and compare the results with the signature sent:
... const signatureHeader = 'Signature-Header' const signatureAlgorithm = 'sha256' const encodeFormat = 'hex' const hmacSecret = process.env.WEBHOOK_SECRET const timestampHeader = 'Request-Timestamp' app.post('/webhook', (req, res) => { ... if (!requestTimestamp || Math.abs(Date.now() - requestTimestamp) > tolerance) { // The request timestamp is outside of the tolerance zone. ... }else{ // The request timestamp is in the tolerance zone. // Create digest with payload+timestamp+hmac secret const hashPayload = req.rawBody+'.'+req.get(timestampHeader) const hmac = crypto.createHmac(signatureAlgorithm, hmacSecret) const digest = Buffer.from(signatureAlgorithm + '=' + hmac.update(hashPayload).digest(encodeFormat), 'utf8') // Get hash sent by the provider const providerSig = Buffer.from(req.get(signatureHeader) || '', 'utf8') // Compare digest signature with signature sent by provider if (providerSig.length !== digest.length || !crypto.timingSafeEqual(digest, providerSig)) { res.status(401).send('Request unauthorized') }else{ // Webhook Authenticated // process and respond res.json({ message: "Success" }) } } })If the result matches, the request is considered legit. If not, the request is considered unauthenticated, or its content and timestamp are modified.
Important Notes
to avoid requests with tampered timestamps, webhook providers must include the timestamp in the signature digest:
const hashPayload = req.rawBody+'.'+req.get(timestampHeader)To ensure the timestamp validation works, you must keep your listener clock in sync with the webhook provider. The use of an NTP server should address this concern.
Tolerance windows vary by provider. Slack recommends 5 minutes, and Stripe's SDKs default to 5 minutes. Linear recommends one minute, and Paddle's SDKs default to five seconds. Stripe warns that a tolerance of
0disables the check entirely.A timestamp tells you when the request was signed, not when the event happened. Stripe generates a new timestamp and signature for each retry, so a retried event still passes a 5-minute window. See Retries and Timeouts.
Some webhook providers — like 8x8, PayPal, GitHub (
X-GitHub-Delivery), and Standard Webhooks implementations (webhook-id) — also send unique ids per webhook notification. Rejecting IDs you've already processed blocks replays even inside the tolerance window, and Mailgun suggests caching each webhook'stokenfor the same reason. This requires consumers to store and keep track of webhook ids previously processed — see Idempotency and Duplicate Events.