HMAC Outbound Signing
Enable HMAC signatures on a webhook destination, and rotate a signing secret safely.
Use this guide to enable HMAC signing for an outbound webhook delivery, and to add signature checks to your receiver.
Purpose#
Use this guide to:
- Enable HMAC signing on a webhook destination.
- Set the signature algorithm and the custom headers.
- Rotate a signing secret during a grace period.
- Add signature checks to a webhook receiver.
How outbound signing operates#
When HMAC signing is enabled on a webhook destination, PayloadRelay calculates a signature over timestamp + "." + body. It sends two headers with each delivery:
X-PayloadRelay-Signature: the Base64-encoded HMAC of the payload.X-PayloadRelay-Timestamp: the Unix timestamp in seconds when PayloadRelay signed the request.
The receiver uses the shared secret to verify the signature and to authenticate the delivery.
Before you start#
- Make sure that you can edit the endpoint.
- Make sure that you can deploy verification code to the webhook receiver.
Procedure#
1. Enable signing on the webhook output#
- Open the endpoint edit page.
- Select the
Outputstab. - Select a webhook destination.
- Enable
HMAC signing. - Select an algorithm:
SHA256,SHA1, orSHA512. The default isSHA256. - Enter a signing secret, or select the
Generatebutton to make one in the browser. - You can set custom signature and timestamp header names. The defaults are
X-PayloadRelay-SignatureandX-PayloadRelay-Timestamp. You can also set a prefix for the Base64 signature value. The signature header, the timestamp header, and the derived<signature-header>-Previousrotation header must have different names. They cannot use restricted HTTP header names. - Save.
PayloadRelay stores the secret in encrypted form, and it signs all the new deliveries. The API does not return the raw secret after you save it.
2. Store the secret securely#
Copy the secret to a safe location, such as a vault or a password manager. The receiver needs this secret to verify a signature.
If you lose the secret, enter a new secret and rotate it as step 4 describes.
3. Add verification to your receiver#
The webhook receiver must:
- Extract the
X-PayloadRelay-SignatureandX-PayloadRelay-Timestampheaders. - Build the signed payload again:
timestamp + "." + raw_body. - Calculate the HMAC with your algorithm and secret.
- Compare the calculated signature to the received signature with a constant-time comparison.
- Apply a replay window. Accept the timestamp only when it is in 5 minutes before or after the current time.
Verification recipe (Node.js):
const crypto = require('crypto');
function signaturesMatch(received, expected) {
if (typeof received !== 'string') {
return false;
}
const receivedBuffer = Buffer.from(received, 'utf8');
const expectedBuffer = Buffer.from(expected, 'utf8');
return receivedBuffer.length === expectedBuffer.length
&& crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
}
function verifyPayloadRelaySignature(req, secret) {
const signature = req.headers['x-payloadrelay-signature'];
const timestamp = req.headers['x-payloadrelay-timestamp'];
const body = Buffer.isBuffer(req.rawBody)
? req.rawBody
: Buffer.from(req.rawBody || '', 'utf8');
if (typeof signature !== 'string' || typeof timestamp !== 'string' || !/^\d+$/.test(timestamp)) {
return false;
}
const timestampInt = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(timestampInt) || Math.abs(now - timestampInt) > 300) {
return false;
}
const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), body]);
const computed = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('base64');
return signaturesMatch(signature, computed);
}
// Usage
if (verifyPayloadRelaySignature(req, process.env.PAYLOADRELAY_SECRET)) {
console.log('Valid signature');
} else {
console.log('Invalid signature');
}Verification recipe (Python):
import hmac
import hashlib
import base64
import os
import time
def verify_payloadrelay_signature(request, secret):
signature = request.headers.get('X-PayloadRelay-Signature')
timestamp = request.headers.get('X-PayloadRelay-Timestamp')
body = request.body # raw request body bytes
if not isinstance(signature, str) or not isinstance(timestamp, str):
return False
if not timestamp.isascii() or not timestamp.isdecimal():
return False
try:
timestamp_int = int(timestamp)
except (TypeError, ValueError):
return False
now = int(time.time())
if abs(now - timestamp_int) > 300:
return False
signed_payload = timestamp.encode('ascii') + b'.' + body
computed = base64.b64encode(
hmac.new(secret.encode(), signed_payload, hashlib.sha256).digest()
).decode()
return hmac.compare_digest(signature, computed)
# Usage
if verify_payloadrelay_signature(request, os.environ['PAYLOADRELAY_SECRET']):
print('Valid signature')
else:
print('Invalid signature')4. Rotate the secret#
Use this procedure to rotate a signing secret with no interruption to the receivers:
- Open the endpoint edit page. Select the
Outputstab, then select the signed webhook destination. - Enter or generate the replacement secret. Copy it to a safe location. Do not save the destination now.
- Deploy receiver code that accepts the current secret and the replacement secret, and that examines the two signature headers. Keep the current secret in the receiver during this deployment.
- When every receiver is ready, select
Keep previous secret accepted for 7 daysand save the destination. The replacement secret becomes the current secret. - Send a controlled test delivery. Make sure that the receiver accepts the current signature.
- When the seven-day grace period ends, remove the old secret from every receiver.
Grace period behavior:
When you rotate the secret, PayloadRelay:
- Makes the replacement secret the current secret.
- Keeps the old secret as the previous secret for 7 days.
- Sends the two signatures on each delivery:
X-PayloadRelay-Signature: signed with the current secret.X-PayloadRelay-Signature-Previous: signed with the previous secret.
The previous-signature header supports a receiver instance that still uses the old secret. The receiver must examine this header. Deploy the verification for the two headers and the two secrets before you save the rotation.
Receiver rotation example:
Deploy a verifier that examines each supplied signature against each temporarily accepted secret:
const crypto = require('crypto');
function signaturesMatch(received, expected) {
if (typeof received !== 'string') {
return false;
}
const receivedBuffer = Buffer.from(received, 'utf8');
const expectedBuffer = Buffer.from(expected, 'utf8');
return receivedBuffer.length === expectedBuffer.length
&& crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
}
function verifyPayloadRelaySignature(req, acceptedSecrets) {
const signatures = [
req.headers['x-payloadrelay-signature'],
req.headers['x-payloadrelay-signature-previous'],
].filter((value) => typeof value === 'string');
const timestamp = req.headers['x-payloadrelay-timestamp'];
const body = Buffer.isBuffer(req.rawBody)
? req.rawBody
: Buffer.from(req.rawBody || '', 'utf8');
if (!Array.isArray(acceptedSecrets)
|| typeof timestamp !== 'string'
|| !/^\d+$/.test(timestamp)
|| signatures.length === 0) {
return false;
}
const timestampInt = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isSafeInteger(timestampInt) || Math.abs(now - timestampInt) > 300) {
return false;
}
const signedPayload = Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), body]);
return acceptedSecrets.filter((secret) => typeof secret === 'string' && secret.length > 0).some((secret) => {
const computed = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('base64');
return signatures.some((signature) => signaturesMatch(signature, computed));
});
}
// Before saving the rotation, deploy with both values configured.
const acceptedSecrets = [
process.env.PAYLOADRELAY_NEW_SECRET,
process.env.PAYLOADRELAY_OLD_SECRET,
].filter(Boolean);After 7 days, PayloadRelay stops the X-PayloadRelay-Signature-Previous header. Make sure that every receiver uses the new secret before the grace period ends.
Header collision rules#
Outbound signing reserves the configured signature header, the timestamp header, and the derived previous-signature header (<signature-header>-Previous). PayloadRelay rejects a configuration when:
- The signature header name and the timestamp header name are the same.
- A custom outbound header uses one of these HMAC header names.
- The outbound API-key authentication uses one of these HMAC header names.
- A signing header uses a restricted HTTP name such as
Authorization,Cookie,Host,Content-Type,Content-Length,Transfer-Encoding, orConnection.
Algorithm support#
PayloadRelay always encodes an outbound HMAC signature as Base64. It supports three HMAC algorithms:
| Algorithm | Security | Notes |
|---|---|---|
SHA256 | Strong | Default. Use it for a new integration. |
SHA1 | Weak | Supported for legacy compatibility only. |
SHA512 | Strong | Uses more computing resources. Use it when the receiver needs it. |
Replay protection#
The receiver must examine X-PayloadRelay-Timestamp to reject a replayed request:
- Parse the timestamp as a Unix timestamp (integer seconds).
- Compare it to the current server time.
- If the difference is more than the permitted time difference, reject the request. Use 5 minutes, or 300 seconds, as the default.
This prevents an attack that captures a valid request and sends it again.
Clock difference:
- PayloadRelay signs each request with the timestamp in the signature header.
- A large difference between the receiver clock and the PayloadRelay clock can cause a valid request to fail.
- To keep the receiver clock accurate, use NTP or an equivalent service.
Expected result#
- A webhook delivery contains
X-PayloadRelay-SignatureandX-PayloadRelay-Timestamp. - A receiver verifies the signatures with the shared secret.
- During a rotation, a receiver accepts the current signature or the previous signature for 7 days.
- After 7 days, PayloadRelay sends only the current signature.
Common issues and fixes#
- A signature mismatch: make sure that the secret is exact. Use the raw request body, not the parsed JSON.
- The timestamp is outside the permitted time difference: synchronize the receiver clock. You can increase the replay window, but a larger window gives less protection.
- Missing headers after a rotation: send a new delivery. PayloadRelay signs a new request immediately after the rotation.
- The old secret is still accepted after 7 days: PayloadRelay sends the previous signature for 7 days. The receiver can still accept the two secrets. After the grace period, update it to trust only the current secret.