Webhook Security
Secure your webhook endpoints with signature verification and best practices to prevent spoofing attacks.
Why Verify Signatures?
Without verification, attackers could send fake webhook events to your endpoint. They might:
- Mark orders as paid without actual payment
- Trigger unintended actions in your system
- Flood your endpoint with fake events
Signature Header
Every webhook includes an x-web3pay-signature header:
x-web3pay-signature: t=1732624500,v1=abc123def456...The header contains two parts:
- t - Unix timestamp when the webhook was sent
- v1 - HMAC-SHA256 signature of the payload
Verification Process
- Extract the timestamp and signature from the header
- Validate the timestamp is recent (within 5 minutes)
- Compute the expected signature using your webhook secret
- Compare signatures using constant-time comparison
Implementation Examples
Node.js
javascript
1400">const crypto = require(400">039;crypto039;);2 3400">const WEBHOOK_SECRET = process.env.WEB3PAY_WEBHOOK_SECRET;4400">const TOLERANCE_SECONDS = 300; 500">// 5 minutes5 6400">function verifyWebhookSignature(payload, signatureHeader) {7 400">if (!signatureHeader) {8 400">throw 400">new Error(400">039;Missing signature header039;);9 }10 11 500">// Parse the signature header12 400">const parts = {};13 signatureHeader.split(400">039;,039;).forEach(part => {14 400">const [key, value] = part.split(400">039;=039;);15 parts[key] = value;16 });17 18 400">const timestamp = parseInt(parts.t, 10);19 400">const signature = parts.v1;20 21 400">if (!timestamp || !signature) {22 400">throw 400">new Error(400">039;Invalid signature format039;);23 }24 25 500">// Verify timestamp is recent26 400">const now = Math.floor(Date.now() / 1000);27 400">if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {28 400">throw 400">new Error(400">039;Timestamp outside tolerance window039;);29 }30 31 500">// Compute expected signature32 400">const signedPayload = 400">`${timestamp}.${payload}`;33 400">const expectedSignature = crypto34 .createHmac(400">039;sha256039;, WEBHOOK_SECRET)35 .update(signedPayload, 400">039;utf8039;)36 .digest(400">039;hex039;);37 38 500">// Constant-time comparison39 400">const signatureBuffer = Buffer.400">from(signature, 400">039;utf8039;);40 400">const expectedBuffer = Buffer.400">from(expectedSignature, 400">039;utf8039;);41 42 400">if (signatureBuffer.length !== expectedBuffer.length) {43 400">throw 400">new Error(400">039;Invalid signature039;);44 }45 46 400">if (!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)) {47 400">throw 400">new Error(400">039;Invalid signature039;);48 }49 50 400">return 400">true;51}Python
python
1400">import hmac2400">import hashlib3400">import time4400">import os5 6WEBHOOK_SECRET = os.environ[400">&500">#039;WEB3PAY_WEBHOOK_SECRET039;]7TOLERANCE_SECONDS = 300 500"># 5 minutes8 9400">def verify_webhook_signature(payload: str, signature_header: str) -> bool:10 400">if not signature_header:11 400">raise ValueError(400">&500">#039;Missing signature header039;)12 13 500"># Parse signature header14 parts = dict(p.split(400">&500">#039;=039;) 400">for p 400">in signature_header.split(400">039;,039;))15 timestamp = int(parts.get(400">&500">#039;t039;, 0))16 signature = parts.get(400">&500">#039;v1039;, 039;400">039;)17 18 400">if not timestamp or not signature:19 400">raise ValueError(&500">#039;Invalid signature format039;)20 21 500"># Check timestamp22 now = int(time.time())23 400">if abs(now - timestamp) > TOLERANCE_SECONDS:24 400">raise ValueError(400">&500">#039;Timestamp outside tolerance window039;)25 26 500"># Compute expected signature27 signed_payload = f400">"{timestamp}.{payload}"28 expected = hmac.new(29 WEBHOOK_SECRET.encode(400">&500">#039;utf-8039;),30 signed_payload.encode(400">&500">#039;utf-8039;),31 hashlib.sha25632 ).hexdigest()33 34 500"># Constant-time comparison35 400">if not hmac.compare_digest(signature, expected):36 400">raise ValueError(400">&500">#039;Invalid signature039;)37 38 400">return 400">TrueGo
go
1package webhook2 3import (4 "crypto/hmac"5 "crypto/sha256"6 "encoding/hex"7 "errors"8 "math"9 "strconv"10 "strings"11 "time"12)13 14const ToleranceSeconds = 30015 16func VerifySignature(payload, signatureHeader, secret string) error {17 if signatureHeader == "" {18 return errors.New("missing signature header")19 }20 21 // Parse header22 var timestamp int6423 var signature string24 for _, part := range strings.Split(signatureHeader, ",") {25 kv := strings.SplitN(part, "=", 2)26 if len(kv) != 2 {27 continue28 }29 switch kv[0] {30 case "t":31 timestamp, _ = strconv.ParseInt(kv[1], 10, 64)32 case "v1":33 signature = kv[1]34 }35 }36 37 if timestamp == 0 || signature == "" {38 return errors.New("invalid signature format")39 }40 41 // Check timestamp42 now := time.Now().Unix()43 if math.Abs(float64(now-timestamp)) > ToleranceSeconds {44 return errors.New("timestamp outside tolerance")45 }46 47 // Compute expected signature48 signedPayload := strconv.FormatInt(timestamp, 10) + "." + payload49 mac := hmac.New(sha256.New, []byte(secret))50 mac.Write([]byte(signedPayload))51 expected := hex.EncodeToString(mac.Sum(nil))52 53 // Constant-time comparison54 if !hmac.Equal([]byte(signature), []byte(expected)) {55 return errors.New("invalid signature")56 }57 58 return nil59}Using the SDK
Our SDK includes a helper function for verification:
javascript
400">import { verifyWebhookSignature } 400">from 400">039;@web3pay/sdk039;;
app.post(400">039;/webhooks039;, express.raw({ 400">type: 400">039;application/json039; }), (req, res) => {
400">const signature = req.headers[400">039;x-web3pay-signature039;];
400">const payload = req.body.toString();
400">try {
verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET);
400">const event = JSON.parse(payload);
500">// Process event...
res.json({ received: 400">true });
} 400">catch (err) {
console.error(400">039;Webhook verification failed:039;, err.message);
res.status(400).send(400">039;Invalid signature039;);
}
});Common Mistakes
Parsing body before verification
Don't parse the JSON before verifying. Use the raw request body for signature computation.
javascript
500">// WRONG - body is parsed
app.post(400">039;/webhooks039;, express.json(), (req, res) => {
500">// req.body is already parsed, can400">039;t verify signature
});
500">// CORRECT - use raw body
app.post(039;/webhooks400">039;, express.raw({ 400">type: 039;application/json039; }), (req, res) => {
400">const rawBody = req.body.toString(); 500">// Use this for verification
500">// Then parse: JSON.parse(rawBody)
});Not using constant-time comparison
Regular string comparison (===) leaks timing information. Always use constant-time comparison functions.
Ignoring the timestamp
Without timestamp validation, attackers could replay old valid webhooks. Always check the timestamp is within tolerance.
Rotating Webhook Secrets
To rotate your webhook secret without downtime:
- Generate a new secret in the dashboard
- Update your code to accept both old and new secrets
- Deploy your updated code
- Delete the old secret in the dashboard
- Remove the old secret from your code
javascript
400">const WEBHOOK_SECRETS = [
process.env.WEBHOOK_SECRET_NEW,
process.env.WEBHOOK_SECRET_OLD
].filter(Boolean);
400">function verifyWithAnySecret(payload, signature) {
for (400">const secret of WEBHOOK_SECRETS) {
400">try {
verifyWebhookSignature(payload, signature, secret);
400">return 400">true;
} 400">catch {
500">// Try next secret
}
}
400">throw 400">new Error(400">039;Invalid signature039;);
}