Webhook Secret Best Practices: Length, Storage, and Zero-Downtime Rotation
How long a webhook secret should be, static tokens vs HMAC signatures, storing secrets in environment variables, and rotating them with zero downtime.
You generated a strong webhook secret. This guide covers everything after that: how long it should be, static tokens versus HMAC signatures, where to store the secret, and how to rotate it with zero downtime. All configuration and commands below were executed and verified against a running Core Webhook Module instance before publishing.
How long should a webhook secret be
32 bytes (256 bits) from a cryptographic random source. As a 64-character hex string, that is beyond any brute-force attack and short enough for every provider's limits. If you need the commands, see how to generate a webhook secret on Linux, macOS, and Windows.
Two practical constraints to check before you generate:
- Provider charset limits. Some senders restrict the characters. Telegram's
secret_token, for example, allows 1-256 characters fromA-Z a-z 0-9 _ -only. Hex output satisfies nearly every such rule, which is one reason to prefer it over base64. - Provider length limits. 64 hex characters fits comfortably inside every mainstream provider's limit (GitHub, Stripe, and Telegram all accept far more).
Longer than 32 bytes adds nothing; entropy beyond 256 bits does not make attacks harder in practice.
Static token or HMAC signature
There are two common ways a secret protects a webhook, and it pays to know which one your sender supports.
A static token travels with every request, usually in a header. The receiver compares it against the expected value. Simple, and fine over HTTPS. In Core Webhook Module this is the authorization key (or header_auth for custom header names like Telegram's):
{
"orders": {
"data_type": "json",
"module": "log",
"authorization": "Bearer {$WEBHOOK_SECRET}"
}
}
An HMAC signature never sends the secret at all. The sender computes HMAC(secret, request_body) and sends only the resulting signature in a header; the receiver recomputes it over the raw body and compares. This is what GitHub's X-Hub-Signature-256 does, and it additionally proves the payload was not modified in transit. In Core Webhook Module:
{
"signed_orders": {
"data_type": "json",
"module": "log",
"hmac": {
"secret": "{$WEBHOOK_SECRET}",
"header": "X-Signature",
"algorithm": "sha256"
}
}
}
You can simulate a signing sender with OpenSSL to test it:
BODY='{"event":"order.created","id":42}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
curl -i -X POST http://localhost:8099/webhook/signed_orders \
-H "Content-Type: application/json" \
-H "X-Signature: sha256=$SIG" \
-d "$BODY"
Verified results: the correctly signed request returns HTTP 200. Tampering with the signature returns HTTP 401 with {"detail":"Invalid HMAC signature"}, and omitting the header returns HTTP 401 with {"detail":"Missing X-Signature header"}. Prefer HMAC whenever the sender offers it; fall back to a static token when it does not.
Either way, the receiver must compare secrets in constant time. String equality (==) leaks timing information that lets an attacker recover a secret byte by byte. Core Webhook Module uses constant-time comparison (hmac.compare_digest) for every auth method, so this is handled for you; if you ever hand-roll a receiver, do not skip it.
Where to store the secret
In an environment variable, never in the config file. Config files get committed to git, copied into backups, and pasted into chat. Core Webhook Module's {$VAR} substitution exists for exactly this reason: the examples above reference {$WEBHOOK_SECRET}, and the value arrives only at runtime:
docker run --rm -d --name webhook -p 8099:8000 \
-v "$PWD/webhooks.json:/app/webhooks.json:ro" \
-v "$PWD/connections.json:/app/connections.json:ro" \
-e WEBHOOK_SECRET=your-generated-secret \
spiderhash/webhook:latest
Three rules that cover most incidents:
- One secret per webhook and per environment. A leak then compromises one integration, not all of them, and staging never validates production traffic.
- Never log the secret. Core Webhook Module redacts sensitive headers in its own log output (they appear as
[REDACTED]), but check anything else in your pipeline, especially reverse proxy logs with header logging enabled. - Treat the secret like a password in your secret manager, CI variables, and deployment tooling. It is one.
Rotating without dropping deliveries
Secrets should change when a person with access leaves, when anything that touched the secret leaks, or on a periodic schedule if compliance requires it. The naive rotation (change both sides, restart everything) drops any webhook delivered in the gap. Core Webhook Module supports live config reload, which closes that gap.
Enable the admin endpoint by setting an admin token when you start the container (generate it the same way as any secret):
-e CONFIG_RELOAD_ADMIN_TOKEN=your-admin-token
Then rotation is three steps:
- Update the secret value (in
webhooks.json, or the environment your process manager injects). - Tell the running instance to reload, no restart:
curl -X POST http://localhost:8099/admin/reload-config \
-H "Authorization: Bearer your-admin-token"
- Update the sender (the provider dashboard or API) with the new secret.
We verified the whole flow: the reload call returns {"status":"success","reloaded":{"webhooks":true,"connections":true}, ...}, requests carrying the new secret immediately return HTTP 200, and the old secret is rejected with HTTP 401 from the same moment, with zero downtime in between. Deliveries the sender signs with the old secret between steps 2 and 3 will be rejected and retried by most providers (GitHub, Stripe, and Telegram all retry failed deliveries), so do steps 2 and 3 back to back and the retry mechanism absorbs the rest.
Wrapping up
Generate 32 random bytes from your OS (commands here, and skip the online generators), prefer HMAC signatures where the sender supports them, keep the secret in environment variables, and rotate through live reload instead of restarts. From there, pick your sender from the step-by-step integration guides: GitHub, GitLab, Stripe, Grafana and the rest of the blog.
Keep reading
- Why You Shouldn't Use an Online Webhook Secret Generator
- How to Generate a Webhook Secret (Copy-Paste Commands for Linux, macOS, Windows)
- How to Receive Webhooks from Mastodon (Self-Hosted, Step by Step)
Browse more: all integration guides · webhook security · HTTPS setup · documentation · what is Core Webhook Module?