documentation/tutorials/webhook-authentication.md
Rama master · réplica de lectura del repositorio original.
---
title: "Webhook HMAC Verification: the Step Most Integrations Skip"
sidebar_label: "Webhook authentication"
description: "Working code for Python, Node.js and Go. Covers HMAC-SHA256 signatures, timestamp validation to block replay attacks, and the IP allowlisting gotcha that breaks production."
keywords: [webhook authentication, HMAC signature verification, webhook security, secure webhooks, replay attack prevention, webhook signature Python, webhook signature Node.js]
faqItems:
- question: "How do I verify a Hook0 webhook signature?"
answer: >-
Hook0 signs every request with HMAC-SHA256 and sends the result in the
X-Hook0-Signature header (t=timestamp, h=header names, v1=signature). To
verify, recompute the HMAC over the timestamp, header names, header
values, and payload joined by dots, using your signing secret, then
compare it to the v1 value and reject anything that does not match.
- question: "How do I protect a webhook endpoint against replay attacks?"
answer: >-
The signature carries a Unix timestamp in its t field. Before checking the
HMAC, reject any request whose timestamp falls outside a tolerance window;
300 seconds is a sensible default. That way a captured but still-valid
delivery cannot be replayed hours later.
- question: "Should I allowlist Hook0's delivery IP addresses?"
answer: >-
No. Hook0 does not guarantee a fixed set of delivery IPs and they can
change without notice, so an allowlist will silently drop real webhooks.
Rely on HMAC signature verification instead, which is cryptographically
secure and does not depend on network configuration.
- question: "How do I rotate a webhook signing secret without downtime?"
answer: >-
Accept both secrets during the overlap. Keep the old and the new secret in
your verifier and pass the request if either one matches, then remove the
old secret once every active subscription has moved over.
---
# Implementing Webhook Authentication
Hook0 signs every delivery with HMAC-SHA256 and sends the result in the `X-Hook0-Signature` header. Verifying it well comes down to five practices: compute the HMAC over the raw request body, never re-serialized JSON; compare signatures in constant time; reject timestamps outside a tolerance window (300 seconds is a sensible default) to block replays; accept both the old and the new secret while you rotate; and never rely on an IP allowlist instead of the signature.
This tutorial puts these practices into code in Python, Node.js and Go, using the [signing secret of the subscription](/concepts/subscriptions#subscription-secrets) that delivered the webhook, then moves on to advanced security patterns.
:::tip Get a real signature to test against
Point a subscription at [Hook0 Play](https://play.hook0.com) to inspect signed requests, or [create a free Hook0 Cloud account](https://app.hook0.com/register) (100 events a day, no credit card).
:::
## Prerequisites
- Completed [Getting Started](./getting-started.md) tutorial up to the signature verification.
- Understanding of cryptographic concepts (HMAC, hashing)
- Basic knowledge of HTTP security
### Set Up Environment Variables
```bash
# Set your service token (from dashboard)
export HOOK0_TOKEN="YOUR_TOKEN_HERE"
export HOOK0_API="https://app.hook0.com/api/v1" # Replace by your domain (or http://localhost:8081 locally)
# Set your application ID (shown in dashboard URL or application details)
export APP_ID="YOUR_APPLICATION_ID_HERE"
```
Save these values:
```bash
# Save to .env file for later use
cat > .env <<EOF
HOOK0_TOKEN=$HOOK0_TOKEN
HOOK0_API=$HOOK0_API
APP_ID=$APP_ID
EOF
```
## Authentication Methods Overview
### 1. HMAC Signature Verification (Recommended)
Hook0's default method using HMAC-SHA256 signatures.
### 2. Custom Headers
Using API keys or tokens in HTTP headers.
## Step 1: Understanding Hook0 Signatures
Hook0 signs every webhook request with HMAC-SHA256. The default signature version is **v1**, which includes selected headers:
```
X-Hook0-Signature: t=1765443663,h=content-type x-custom-header,v1=85da0586ae0b711d...
```
### Signature Components
- **t**: Unix timestamp (seconds)
- **h**: Space-separated list of header names included in signature
- **v1**: HMAC-SHA256 signature (hex-encoded)
### Signature Computation (v1)
```
HMAC-SHA256(secret, timestamp + "." + header_names + "." + header_values + "." + payload)
```
Where `header_names` is the `h=` value and `header_values` is the values of those headers joined by `.`
:::tip Inspect webhook signatures live
Open [Hook0 Play](https://play.hook0.com) and send a signed webhook to see the exact headers and HMAC signature your endpoint will receive.
:::
## Step 2: Basic Signature Verification
### Node.js Implementation
```javascript
const crypto = require('crypto');
const express = require('express');
const app = express();
// Capture raw body for signature verification
app.use('/webhooks', express.json({
verify: (req, res, buf) => { req.rawBody = buf; }
}));
function verifyHook0Signature(rawBody, signature, headers, secret) {
// An unset secret is one anyone can sign with: never let it verify anything
if (!secret) return false;
const parts = Object.fromEntries(signature.split(',').map(p => p.split('=')));
const headerNames = parts.h ? parts.h.split(' ') : [];
const headerValues = headerNames.map(h => headers[h] || '').join('.');
// Use raw body string directly, not JSON.stringify(parsedBody)
const signedData = parts.h
? `${parts.t}.${parts.h}.${headerValues}.${rawBody}`
: `${parts.t}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret).update(signedData).digest('hex');
return parts.v1 === expected;
}
app.post('/webhooks/secure', (req, res) => {
const signature = req.headers['x-hook0-signature'];
const rawBodyString = req.rawBody.toString('utf8');
if (!signature || !verifyHook0Signature(rawBodyString, signature, req.headers, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
console.log('Verified webhook:', req.body);
res.json({ status: 'verified' });
});
app.listen(3000);
```
:::tip Using the Official SDK
For production use, reach for the official client for your language rather than the code above. All eleven verify signatures, and each page shows the call: [JavaScript](../reference/sdk/javascript.md), [Rust](../reference/sdk/rust.md), [Python](../reference/sdk/python.md), [Go](../reference/sdk/go.md), [Ruby](../reference/sdk/ruby.md), [PHP](../reference/sdk/php.md), [C#](../reference/sdk/csharp.md), [Java](../reference/sdk/java.md), [Kotlin](../reference/sdk/kotlin.md), [Lua](../reference/sdk/lua.md), [Zig](../reference/sdk/zig.md).
In Node.js, that is the `hook0-client` npm package:
```javascript
const { verifyWebhookSignature } = require('hook0-client');
// Capture raw body for signature verification
app.post('/webhooks', express.json({
verify: (req, res, buf) => { req.rawBody = buf; }
}), (req, res) => {
const signature = req.headers['x-hook0-signature'];
const rawBodyString = req.rawBody.toString('utf8');
try {
verifyWebhookSignature(signature, rawBodyString, req.headers, process.env.WEBHOOK_SECRET, 300);
// Process webhook...
res.json({ received: true });
} catch (error) {
res.status(401).json({ error: error.message });
}
});
```
:::
### Python Implementation
:::tip The Python SDK does this for you
`hook0-client` ships `verify_webhook_signature`, which handles both signature schemes, the bilateral clock window and the covered headers. The code below is here to show what it computes. See the [Python SDK](../reference/sdk/python.md).
:::
```python
import hmac
import hashlib
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
def verify_hook0_signature(body: bytes, signature: str, headers: dict, secret: str) -> bool:
# An unset secret is one anyone can sign with: never let it verify anything
if not secret:
return False
parts = dict(p.split('=') for p in signature.split(','))
header_names = parts.get('h', '').split(' ') if parts.get('h') else []
header_values = '.'.join(headers.get(h, '') for h in header_names)
signed_data = f"{parts['t']}.{parts['h']}.{header_values}.{body.decode()}" if parts.get('h') else f"{parts['t']}.{body.decode()}"
expected = hmac.new(secret.encode(), signed_data.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts['v1'])
@app.route('/webhooks/secure', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Hook0-Signature')
if not signature or not verify_hook0_signature(request.data, signature, request.headers, os.environ['WEBHOOK_SECRET']):
return jsonify({'error': 'Invalid signature'}), 401
payload = request.get_json()
print(f"Verified webhook: {payload}")
return jsonify({'status': 'verified'})
if __name__ == '__main__':
app.run(port=3000)
```
### Go Implementation
:::tip The Go SDK does this for you
`hook0.VerifyWebhookSignature` handles both signature schemes, the bilateral clock window and the covered headers, and returns errors you match with `errors.Is`. The code below is here to show what it computes. See the [Go SDK](../reference/sdk/go.md).
:::
```go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func verifyHook0Signature(body []byte, signature, secret string, headers http.Header) bool {
// os.Getenv answers "" for an unset variable, and anyone can sign with that
if secret == "" {
return false
}
parts := make(map[string]string)
for _, p := range strings.Split(signature, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
var signedData string
if h, ok := parts["h"]; ok && h != "" {
headerNames := strings.Split(h, " ")
var headerValues []string
for _, name := range headerNames {
headerValues = append(headerValues, headers.Get(name))
}
signedData = fmt.Sprintf("%s.%s.%s.%s", parts["t"], h, strings.Join(headerValues, "."), body)
} else {
signedData = fmt.Sprintf("%s.%s", parts["t"], body)
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedData))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
signature := r.Header.Get("X-Hook0-Signature")
if !verifyHook0Signature(body, signature, os.Getenv("WEBHOOK_SECRET"), r.Header) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var payload map[string]interface{}
json.Unmarshal(body, &payload)
fmt.Printf("Verified webhook: %v\n", payload)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "verified"})
}
func main() {
http.HandleFunc("/webhooks/secure", webhookHandler)
http.ListenAndServe(":3000", nil)
}
```
## Step 3: Advanced Signature Verification
### Secret Rotation Support
During rotation, accept signatures from both old and new secrets:
```javascript
function verifyWithSecrets(rawBody, signature, headers, secrets) {
return secrets.some(s => verifyHook0Signature(rawBody, signature, headers, s));
}
const secrets = [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET_OLD].filter(Boolean);
const rawBodyString = req.rawBody.toString('utf8');
if (!verifyWithSecrets(rawBodyString, signature, req.headers, secrets)) {
return res.status(401).json({ error: 'Invalid signature' });
}
```
### Replay Attack Protection
The timestamp (`t`) in the signature prevents replay attacks. To enforce a time window:
```javascript
function verifyHook0Signature(payload, signature, headers, secret, toleranceSec = 300) {
if (!secret) return false; // an unset secret must never verify anything
const parts = Object.fromEntries(signature.split(',').map(p => p.split('=')));
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(parts.t)) > toleranceSec) return false;
const headerNames = parts.h ? parts.h.split(' ') : [];
const headerValues = headerNames.map(h => headers[h] || '').join('.');
const signedData = parts.h
? `${parts.t}.${parts.h}.${headerValues}.${payload}`
: `${parts.t}.${payload}`;
const expected = crypto.createHmac('sha256', secret).update(signedData).digest('hex');
return parts.v1 === expected;
}
```
## Step 4: Custom Header Authentication
Add additional authentication headers to your [subscriptions](/concepts/subscriptions):
### Create Subscription with Custom Headers
```bash
curl -X POST "$HOOK0_API/subscriptions" \
-H "Authorization: Bearer $HOOK0_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"application_id": "'"$APP_ID"'",
"is_enabled": true,
"event_types": ["user.account.created"],
"description": "Webhook with custom authentication",
"labels": {
"environment": "production"
},
"target": {
"type": "http",
"method": "POST",
"url": "https://your-api.com/webhooks/authenticated",
"headers": {
"Content-Type": "application/json",
"X-API-Key": "your-api-key-here",
"X-Webhook-Source": "hook0",
"Authorization": "Bearer your-bearer-token"
}
}
}'
```
### Verify Custom Headers
```javascript
// Note: Express.js normalizes all header names to lowercase
app.post('/webhooks/authenticated', (req, res) => {
const signature = req.headers['x-hook0-signature'];
const rawBodyString = req.rawBody.toString('utf8');
const apiKey = req.headers['x-api-key'];
const webhookSource = req.headers['x-webhook-source'];
const authorization = req.headers['authorization'];
// Verify Hook0 signature
if (!verifyHook0Signature(rawBodyString, signature, req.headers, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Verify custom API key
if (apiKey !== process.env.EXPECTED_API_KEY) {
return res.status(401).json({ error: 'Invalid API key' });
}
// Verify webhook source
if (webhookSource !== 'hook0') {
return res.status(401).json({ error: 'Invalid webhook source' });
}
// Verify bearer token
const expectedToken = `Bearer ${process.env.EXPECTED_BEARER_TOKEN}`;
if (authorization !== expectedToken) {
return res.status(401).json({ error: 'Invalid authorization' });
}
console.log('Multi-factor authenticated webhook:', req.body.event_type);
res.json({ status: 'authenticated' });
});
```
## Step 5: About IP Allowlisting
:::danger Not Recommended
IP allowlisting is **not recommended** for Hook0 webhooks. Hook0 does not guarantee a fixed set of IP addresses for webhook delivery, and IPs may change without notice.
**Always rely on signature verification** as your primary authentication mechanism - it's cryptographically secure and doesn't depend on network configuration.
If you have specific compliance requirements that mandate IP filtering, contact Hook0 support about enterprise options.
:::
## Step 6: Authentication Middleware
Create reusable authentication middleware that properly handles the Hook0 signature format (`t=...,v1=...`):
```javascript
// auth-middleware.js
const crypto = require('crypto');
class WebhookAuth {
constructor(options = {}) {
this.secrets = options.secrets || [];
this.timestampTolerance = options.timestampTolerance || 300;
this.requiredHeaders = options.requiredHeaders || {};
}
// Parse the Hook0 signature format: "t=123456,h=content-type,v1=abc123..."
parseSignature(signature) {
return Object.fromEntries(
signature.split(',').map(part => {
const [key, ...valueParts] = part.split('=');
return [key, valueParts.join('=')]; // Handle '=' in values
})
);
}
// Compute the expected signature based on Hook0's spec
computeSignature(payload, headers, parts, secret) {
let signedData;
if (parts.h) {
const headerNames = parts.h.split(' ');
const headerValues = headerNames.map(h => headers[h.toLowerCase()] || '').join('.');
signedData = `${parts.t}.${parts.h}.${headerValues}.${payload}`;
} else {
signedData = `${parts.t}.${payload}`;
}
return crypto.createHmac('sha256', secret).update(signedData).digest('hex');
}
verifyHeaders(headers) {
for (const [key, expectedValue] of Object.entries(this.requiredHeaders)) {
if (headers[key.toLowerCase()] !== expectedValue) {
return false;
}
}
return true;
}
middleware() {
return (req, res, next) => {
// 1. Custom header verification
if (!this.verifyHeaders(req.headers)) {
return res.status(401).json({ error: 'Required headers missing or invalid' });
}
// 2. Signature header presence
const signatureHeader = req.headers['x-hook0-signature'];
if (!signatureHeader) {
return res.status(401).json({ error: 'Missing signature' });
}
// 3. Parse the signature format: t=...,v1=...
const parts = this.parseSignature(signatureHeader);
if (!parts.t || !parts.v1) {
return res.status(401).json({ error: 'Invalid signature format' });
}
// 4. Timestamp validation (replay attack protection)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(parts.t)) > this.timestampTolerance) {
return res.status(401).json({ error: 'Timestamp too old or in future' });
}
// 5. Verify signature against all secrets (rotation support)
const payload = req.rawBody.toString('utf8');
let signatureValid = false;
for (const secret of this.secrets) {
const expected = this.computeSignature(payload, req.headers, parts, secret);
try {
if (crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))) {
signatureValid = true;
break;
}
} catch (e) {
// Length mismatch - signatures don't match
continue;
}
}
if (!signatureValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 6. Attach parsed body for the controller
req.webhook = req.body;
next();
};
}
}
module.exports = WebhookAuth;
```
### Use the Middleware
```javascript
const WebhookAuth = require('./auth-middleware');
const webhookAuth = new WebhookAuth({
secrets: [
process.env.WEBHOOK_SECRET_CURRENT,
process.env.WEBHOOK_SECRET_PREVIOUS // For rotation support
].filter(Boolean),
timestampTolerance: 300, // 5 minutes
requiredHeaders: {
'x-webhook-source': 'hook0' // Optional custom header check
}
});
// Capture raw body for signature verification
app.use('/webhooks', express.json({
verify: (req, res, buf) => { req.rawBody = buf; }
}));
app.use('/webhooks', webhookAuth.middleware());
app.post('/webhooks/secure', (req, res) => {
// req.webhook contains the parsed and verified payload
console.log('Authenticated webhook:', req.webhook.event_type);
res.json({ status: 'authenticated' });
});
```
## Step 7: Testing Authentication
Create a test script to verify your authentication. The script must generate signatures in Hook0's format (`t=...,v1=...`):
```javascript
// test-auth.js (requires Node.js 18+)
const crypto = require('crypto');
// Generate a Hook0-compatible signature: t={timestamp},v1={hmac}
function generateHook0Signature(payload, secret, timestamp) {
// Simple format without header signing (h= is optional)
const signedData = `${timestamp}.${payload}`;
const v1 = crypto.createHmac('sha256', secret).update(signedData).digest('hex');
return `t=${timestamp},v1=${v1}`;
}
async function testWebhook(url, payloadObj, secret, extraHeaders = {}) {
const payload = JSON.stringify(payloadObj);
const timestamp = Math.floor(Date.now() / 1000);
const signature = generateHook0Signature(payload, secret, timestamp);
console.log(`\n📤 Sending webhook to ${url}`);
console.log(` Signature: ${signature}`);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Hook0-Signature': signature,
'X-Webhook-Source': 'hook0',
...extraHeaders
},
body: payload
});
const text = await response.text();
if (response.ok) {
console.log(` ✅ ${response.status}: ${text}`);
} else {
console.log(` ❌ ${response.status}: ${text}`);
}
} catch (err) {
console.error(` ❌ Request failed:`, err.message);
}
}
async function runTests() {
const SECRET = 'your-webhook-secret';
const URL = 'http://localhost:3000/webhooks/secure';
console.log('🧪 Testing Webhook Authentication\n');
// Test 1: Valid signature
console.log('Test 1: Valid signature');
await testWebhook(URL, {
event_id: 'evt_123',
event_type: 'user.account.created',
payload: { user_id: 'user_123' }
}, SECRET);
// Test 2: Invalid signature (wrong secret)
console.log('\nTest 2: Invalid signature (wrong secret)');
await testWebhook(URL, {
event_id: 'evt_456',
event_type: 'user.account.created',
payload: { user_id: 'user_456' }
}, 'wrong-secret');
// Test 3: Expired timestamp (replay attack)
console.log('\nTest 3: Expired timestamp (6 minutes ago)');
const oldTimestamp = Math.floor(Date.now() / 1000) - 360;
const payload = JSON.stringify({ event_id: 'evt_789', event_type: 'test' });
const oldSignature = generateHook0Signature(payload, SECRET, oldTimestamp);
const response = await fetch(URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Hook0-Signature': oldSignature,
'X-Webhook-Source': 'hook0'
},
body: payload
});
console.log(` ${response.ok ? '✅' : '❌'} ${response.status}: ${await response.text()}`);
console.log('\n✨ Tests completed!');
}
runTests();
```
Run the tests:
```bash
node test-auth.js
```
## Best Practices
### Security
- ✅ Always verify HMAC signatures
- ✅ Use timing-safe comparison for signatures
- ✅ Implement timestamp validation to prevent replay attacks
- ✅ Store secrets securely (environment variables, key management)
- ✅ Use HTTPS for all webhook endpoints
- ✅ Log authentication failures for monitoring
- ❌ Do not log webhook secrets
- ❌ Do not skip signature verification
### Implementation
- ✅ Create reusable authentication middleware
- ✅ Support secret rotation with multiple valid secrets
- ✅ Return appropriate HTTP status codes
- ✅ Validate request format before processing
- ❌ Do not process webhooks with invalid authentication
- ❌ Do not expose internal error details in responses
## What You've Learned
✅ Implemented HMAC-SHA256 signature verification
✅ Built multi-language webhook authentication
✅ Created advanced security patterns (timestamp validation, replay attack protection)
✅ Configured custom header authentication
✅ Built reusable authentication middleware
✅ Tested authentication implementations
## Next Steps
- [Securing Webhook Endpoints](../how-to-guides/secure-webhook-endpoints.md)
- [Debugging Failed Webhook Deliveries](../how-to-guides/debug-failed-webhooks.md)
- [Webhook best practices](../how-to-guides/webhook-best-practices.md) - Payload design, idempotency, versioning, and more security patterns
## Troubleshooting
### Signature Verification Fails
1. Check you're using the raw request body (not parsed JSON)
2. Verify the correct subscription secret
3. Ensure consistent character encoding (UTF-8)
4. Check HMAC algorithm (SHA256, not SHA1)
### Timestamp Validation Issues
1. Verify timestamp format (Unix timestamp in seconds)
2. Check server clock synchronization
3. Adjust tolerance window if needed
4. Handle timezone differences correctly