Webhook Security
Inbound webhooks allow external services to push events into your Grove application. Because webhooks are publicly accessible HTTP endpoints, they require cryptographic verification to ensure that payloads originate from trusted sources and have not been tampered with in transit.
Prerequisites: Credential Store for secret management, Declaration Grammar for webhook syntax. What you'll learn: How Grove verifies webhook signatures, the HMAC-SHA256 verification process, and how to configure provider-specific profiles.
Overview
Grove's webhook security model is built on HMAC-based signature verification. When an external service sends a webhook, it signs the payload with a shared secret. Grove verifies that signature before processing the payload.
The workflow is:
- You register a shared secret with the external provider and store it in the Credential Store.
- You define a
webhookdeclaration with aproviderprofile andsecretreference. - When a request arrives, the Grove runtime extracts the signature from the headers, computes the expected signature, and compares them using constant-time comparison.
- If verification succeeds, the handler is invoked. If it fails, the request is rejected with HTTP 401.
HMAC-SHA256 Verification
The default signature algorithm is HMAC-SHA256. The verification process:
expected = HMAC-SHA256(secret, payload_bytes)
actual = decode(request.headers[signature_header])
verified = constant_time_equal(expected, actual)
Constant-Time Comparison
Grove always uses constant-time comparison for signature verification to prevent timing attacks. This is handled automatically by the runtime; you do not need to implement it yourself.
Webhook Declaration
webhook stripe_events {
provider: "stripe"
secret: credentials.stripe_webhook_secret
path: "/webhooks/stripe"
handler: handle_stripe_event
}
Fields
| Field | Type | Required | Description |
|---|---|---|---|
provider | String | Yes | Provider profile name (see below) |
secret | credential ref | Yes | Reference to the signing secret in the credential store |
path | String | Yes | URL path where the webhook listens |
handler | action ref | Yes | The action invoked when verification succeeds |
fallback_secret | credential ref | No | A secondary secret to try during rotation |
tolerance | Duration | No | Maximum age of a webhook before rejection (replay protection) |
Provider Profiles
Provider profiles define the signature extraction and verification behavior for common webhook sources. Each profile knows which headers to check, how the signature is encoded, and any provider-specific quirks.
Built-in Providers
stripe
Stripe signs webhooks using HMAC-SHA256 with a whsec_-prefixed secret. The signature is in the Stripe-Signature header.
webhook stripe_payments {
provider: "stripe"
secret: credentials.stripe_webhook_secret
path: "/webhooks/stripe"
handler: handle_stripe_event
tolerance: Duration.minutes(5)
}
| Detail | Value |
|---|---|
| Signature header | Stripe-Signature |
| Algorithm | HMAC-SHA256 |
| Payload format | timestamp.payload |
| Encoding | Hex |
| Replay protection | Timestamp in signature header |
github
GitHub signs webhooks using HMAC-SHA256. The signature is in the X-Hub-Signature-256 header.
webhook github_events {
provider: "github"
secret: credentials.github_webhook_secret
path: "/webhooks/github"
handler: handle_github_event
}
| Detail | Value |
|---|---|
| Signature header | X-Hub-Signature-256 |
| Algorithm | HMAC-SHA256 |
| Payload format | Raw body |
| Encoding | sha256= prefix + hex |
| Replay protection | None (use X-GitHub-Delivery for idempotency) |
slack
Slack uses HMAC-SHA256 with a versioned signature scheme. The signature is in the X-Slack-Signature header.
webhook slack_commands {
provider: "slack"
secret: credentials.slack_signing_secret
path: "/webhooks/slack"
handler: handle_slack_command
tolerance: Duration.minutes(5)
}
| Detail | Value |
|---|---|
| Signature header | X-Slack-Signature |
| Timestamp header | X-Slack-Request-Timestamp |
| Algorithm | HMAC-SHA256 |
| Payload format | v0:timestamp:body |
| Encoding | v0= prefix + hex |
| Replay protection | Timestamp header checked against tolerance |
generic
For providers without a built-in profile, use the generic provider with explicit configuration:
webhook custom_events {
provider: "generic"
secret: credentials.custom_webhook_secret
path: "/webhooks/custom"
handler: handle_custom_event
signature_header: "X-Webhook-Signature"
algorithm: "hmac-sha256"
encoding: "hex"
}
Generic Provider Options
| Field | Type | Default | Description |
|---|---|---|---|
signature_header | String | Required | Header containing the signature |
timestamp_header | String | None | Header containing the request timestamp |
algorithm | String | "hmac-sha256" | Signature algorithm |
encoding | String | "hex" | Signature encoding (hex or base64) |
prefix | String | None | Prefix to strip from the signature value (e.g., "sha256=") |
payload_template | String | Raw body | Template for constructing the signing payload |
Replay Protection
Replay protection prevents an attacker from re-sending a previously captured webhook payload. Grove supports two mechanisms:
Timestamp-Based Tolerance
When a tolerance is configured, Grove checks that the webhook's timestamp (from the provider-specific timestamp header or signature component) is within the specified duration of the current time:
webhook stripe_payments {
provider: "stripe"
secret: credentials.stripe_webhook_secret
path: "/webhooks/stripe"
handler: handle_stripe_event
tolerance: Duration.minutes(5) // reject webhooks older than 5 minutes
}
If the timestamp is outside the tolerance window, the webhook is rejected with HTTP 401.
Idempotency-Based Deduplication
Some providers include a unique delivery ID in the headers (e.g., GitHub's X-GitHub-Delivery). Grove does not deduplicate automatically, but your handler can use this ID for idempotency:
action handle_github_event(payload: Value, headers: Map<String, String>) {
let delivery_id = headers["X-GitHub-Delivery"]
if already_processed(delivery_id) {
return
}
// Process the event...
}
Secret Rotation for Webhooks
During webhook secret rotation, you may receive payloads signed with either the old or new secret. Use the fallback_secret field to handle this:
webhook stripe_payments {
provider: "stripe"
secret: credentials.stripe_webhook_secret
fallback_secret: credentials.stripe_webhook_secret.previous
path: "/webhooks/stripe"
handler: handle_stripe_event
}
When fallback_secret is configured:
- Grove first verifies against the primary
secret. - If that fails, it verifies against the
fallback_secret. - If both fail, the request is rejected.
This allows you to rotate secrets with zero downtime.
Error Responses
| Scenario | HTTP Status | Body |
|---|---|---|
| Missing signature header | 401 | {"error": "missing signature header"} |
| Invalid signature | 401 | {"error": "invalid webhook signature"} |
| Expired timestamp | 401 | {"error": "webhook timestamp outside tolerance"} |
| Handler failure | 500 | {"error": "internal server error"} |
| Handler success | 200 | {"ok": true} |
Error responses intentionally omit details about which secret or header was checked to avoid leaking information to potential attackers.
See Also
- Credential Store -- managing webhook signing secrets
- Authorization Hooks -- broader authorization framework
- Declaration Grammar -- webhook declaration syntax
- Server -- how webhook endpoints are served