An Otoroshi plugin that validates webhook payloads before they reach your backend using body payload signature validation.
The plugin is provider-agnostic: the signature header, HMAC algorithm, prefix and signing payload template are all configurable.
The plugin:
- Reads the raw request body.
- Optionally resolves a timestamp (from a dedicated header or extracted from the signature header via regex).
- Builds the signing payload by applying the
signing_payload_template(e.g.{timestamp}.{body}for Stripe,v0:{timestamp}:{body}for Slack, or plain{body}for most providers). - Computes
HMAC-<algorithm>(secret, signingPayload)using the configured secret and algorithm. - Prepends the configured prefix to the hex-encoded hash to form the expected signature.
- Optionally extracts the actual signature from the header value using a regex (e.g.
v1=([^,]+)for Stripe). - Compares the result (constant-time, to prevent timing attacks) against the extracted or raw signature header value.
- Forwards the request to your backend unchanged when the signature is valid.
- Returns 401 Unauthorized when the signature is missing, the timestamp cannot be resolved, or the signature is invalid.
$ curl -X POST 'http://otoroshi-api.oto.tools:8080/api/routes' \
-H "Content-type: application/json" \
-u 'admin-api-apikey-id:admin-api-apikey-secret' \
-d '{
"name": "github-webhook-receiver",
"frontend": {
"domains": ["webhooks.oto.tools/github"]
},
"backend": {
"targets": [{
"hostname": "my-backend.example.com",
"port": 443,
"tls": true
}]
},
"plugins": [
{
"enabled": true,
"plugin": "cp:otoroshi_plugins.com.cloud.apim.otoroshi.plugins.webhook.WebhookPayloadValidator",
"config": {
"secret": "your-github-webhook-secret",
"signature_header": "X-Hub-Signature-256",
"algorithm": "HmacSHA256",
"prefix": "sha256="
}
}
]
}'$ curl -X POST 'http://otoroshi-api.oto.tools:8080/api/routes' \
-H "Content-type: application/json" \
-u 'admin-api-apikey-id:admin-api-apikey-secret' \
-d '{
"name": "yousign-webhook-receiver",
"frontend": {
"domains": ["webhooks.oto.tools/yousign"]
},
"backend": {
"targets": [{
"hostname": "my-backend.example.com",
"port": 443,
"tls": true
}]
},
"plugins": [
{
"enabled": true,
"plugin": "cp:otoroshi_plugins.com.cloud.apim.otoroshi.plugins.webhook.WebhookPayloadValidator",
"config": {
"secret": "your-yousign-webhook-secret",
"signature_header": "X-Yousign-Signature-256",
"algorithm": "HmacSHA256",
"prefix": "sha256="
}
}
]
}'Stripe signs the payload as {timestamp}.{body} and sends the signature as t=<timestamp>,v1=<sig> in the Stripe-Signature header. Both the timestamp and the signature must be extracted from that header with a regex.
$ curl -X POST 'http://otoroshi-api.oto.tools:8080/api/routes' \
-H "Content-type: application/json" \
-u 'admin-api-apikey-id:admin-api-apikey-secret' \
-d '{
"name": "stripe-webhook-receiver",
"frontend": {
"domains": ["webhooks.oto.tools/stripe"]
},
"backend": {
"targets": [{
"hostname": "my-backend.example.com",
"port": 443,
"tls": true
}]
},
"plugins": [
{
"enabled": true,
"plugin": "cp:otoroshi_plugins.com.cloud.apim.otoroshi.plugins.webhook.WebhookPayloadValidator",
"config": {
"secret": "whsec_your-stripe-webhook-secret",
"signature_header": "Stripe-Signature",
"algorithm": "HmacSHA256",
"prefix": "",
"signing_payload_template": "{timestamp}.{body}",
"timestamp_extraction_regex": "t=([^,]+)",
"signature_extraction_regex": "v1=([^,]+)"
}
}
]
}'Slack signs the payload as v0:{timestamp}:{body} and sends the timestamp in a separate X-Slack-Request-Timestamp header. The signature header value is v0=<sig>.
$ curl -X POST 'http://otoroshi-api.oto.tools:8080/api/routes' \
-H "Content-type: application/json" \
-u 'admin-api-apikey-id:admin-api-apikey-secret' \
-d '{
"name": "slack-webhook-receiver",
"frontend": {
"domains": ["webhooks.oto.tools/slack"]
},
"backend": {
"targets": [{
"hostname": "my-backend.example.com",
"port": 443,
"tls": true
}]
},
"plugins": [
{
"enabled": true,
"plugin": "cp:otoroshi_plugins.com.cloud.apim.otoroshi.plugins.webhook.WebhookPayloadValidator",
"config": {
"secret": "your-slack-signing-secret",
"signature_header": "X-Slack-Signature",
"algorithm": "HmacSHA256",
"prefix": "v0=",
"signing_payload_template": "v0:{timestamp}:{body}",
"timestamp_header": "X-Slack-Request-Timestamp"
}
}
]
}'| Field | Type | Required | Default | Description |
|---|---|---|---|---|
secret |
string |
yes | – | The HMAC secret shared with the webhook provider. |
signature_header |
string |
no | X-Hub-Signature-256 |
Name of the HTTP header that carries the signature. |
algorithm |
string |
no | HmacSHA256 |
Java HMAC algorithm name. Supported values: HmacSHA256, HmacSHA512, HmacSHA384, HmacSHA1. |
prefix |
string |
no | derived from algorithm |
String prepended to the hex hash before comparison (e.g. sha256=). Use "" when the comparison value is raw hex (Stripe). |
signing_payload_template |
string |
no | {body} |
Template for the HMAC input. Supports {body} (raw body) and {timestamp}. Examples: {timestamp}.{body} (Stripe), v0:{timestamp}:{body} (Slack). |
timestamp_header |
string |
no | "" |
Name of a separate HTTP header containing the timestamp (e.g. X-Slack-Request-Timestamp for Slack). |
timestamp_extraction_regex |
string |
no | "" |
Regex with one capture group to extract the timestamp from the signature header value (e.g. t=([^,]+) for Stripe). |
signature_extraction_regex |
string |
no | "" |
Regex with one capture group to extract the actual signature from the signature header value (e.g. v1=([^,]+) for Stripe). When empty the full header value is used. |
{
"secret": "your-webhook-secret",
"signature_header": "X-Hub-Signature-256",
"algorithm": "HmacSHA256",
"prefix": "sha256=",
"signing_payload_template": "{body}",
"timestamp_header": "",
"timestamp_extraction_regex": "",
"signature_extraction_regex": ""
}algorithm |
Default prefix |
|---|---|
HmacSHA256 |
sha256= |
HmacSHA512 |
sha512= |
HmacSHA384 |
sha384= |
HmacSHA1 |
sha1= |
| Status | Body | Meaning |
|---|---|---|
| forwarded to backend | – | Signature is valid, request is passed through unchanged. |
401 Unauthorized |
{ "error": "missing xxxx header" } |
The signature header was not present in the incoming request. |
401 Unauthorized |
{ "error": "missing timestamp" } |
The timestamp could not be resolved (missing header or regex did not match). |
401 Unauthorized |
{ "error": "invalid signature" } |
The computed HMAC does not match the header value. |
401 Unauthorized |
{ "error": "webhook secret not configured" } |
The plugin secret field is empty. |
- The plugin uses constant-time byte comparison (
MessageDigest.isEqual) to prevent timing-based side-channel attacks.
sbt assemblyThe resulting jar is placed in target/scala-2.12/otoroshi-plugin-webhook-validator-assembly_2.12-dev.jar.
Copy it to your Otoroshi plugins/ directory (or reference it via the classpath loader) and restart Otoroshi.