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:

  1. You register a shared secret with the external provider and store it in the Credential Store.
  2. You define a webhook declaration with a provider profile and secret reference.
  3. When a request arrives, the Grove runtime extracts the signature from the headers, computes the expected signature, and compares them using constant-time comparison.
  4. 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

FieldTypeRequiredDescription
providerStringYesProvider profile name (see below)
secretcredential refYesReference to the signing secret in the credential store
pathStringYesURL path where the webhook listens
handleraction refYesThe action invoked when verification succeeds
fallback_secretcredential refNoA secondary secret to try during rotation
toleranceDurationNoMaximum 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)
}
DetailValue
Signature headerStripe-Signature
AlgorithmHMAC-SHA256
Payload formattimestamp.payload
EncodingHex
Replay protectionTimestamp 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
}
DetailValue
Signature headerX-Hub-Signature-256
AlgorithmHMAC-SHA256
Payload formatRaw body
Encodingsha256= prefix + hex
Replay protectionNone (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)
}
DetailValue
Signature headerX-Slack-Signature
Timestamp headerX-Slack-Request-Timestamp
AlgorithmHMAC-SHA256
Payload formatv0:timestamp:body
Encodingv0= prefix + hex
Replay protectionTimestamp 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

FieldTypeDefaultDescription
signature_headerStringRequiredHeader containing the signature
timestamp_headerStringNoneHeader containing the request timestamp
algorithmString"hmac-sha256"Signature algorithm
encodingString"hex"Signature encoding (hex or base64)
prefixStringNonePrefix to strip from the signature value (e.g., "sha256=")
payload_templateStringRaw bodyTemplate 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:

  1. Grove first verifies against the primary secret.
  2. If that fails, it verifies against the fallback_secret.
  3. If both fail, the request is rejected.

This allows you to rotate secrets with zero downtime.

Error Responses

ScenarioHTTP StatusBody
Missing signature header401{"error": "missing signature header"}
Invalid signature401{"error": "invalid webhook signature"}
Expired timestamp401{"error": "webhook timestamp outside tolerance"}
Handler failure500{"error": "internal server error"}
Handler success200{"ok": true}

Error responses intentionally omit details about which secret or header was checked to avoid leaking information to potential attackers.

See Also