Rotating HMAC Webhook Signing Secrets Without Dropping Events
Symptom Statement Link to this section
You rotate the webhook signing secret — a Stripe whsec_, a GitHub secret, or your own HMAC key — flip the environment variable, redeploy, and within seconds the endpoint starts returning a wall of HTTP 401 signature rejects. Every in-flight delivery signed with the old secret now fails verification, the sender’s retry queue swells, and any event whose retry budget expires before you finish the cutover is gone for good. The registration row never confirms, the badge never queues, and the only trace left is a burst of signature mismatch log lines timestamped to the exact minute you rotated. This is the classic hard-cutover failure at the ingress boundary: verification is bound to a single live secret, so the instant that secret changes there is a window where legitimate, correctly-signed traffic is indistinguishable from a forgery. This page is the rotation-specific implementation of the Security Boundary Configuration stage, whose HMAC verification and deny-by-default posture this fix has to preserve while making the secret itself a moving target. The provider-side reconciliation of the underlying charges lives in payment webhook handling; here the concern is narrower — never reject a genuine signature just because a key is mid-rotation.
Root Cause Analysis Link to this section
The 401 burst at cutover is almost always one of four distinct mistakes. They stack, but each is independently fixable, so name the one you have before you touch the verifier.
- Single-secret validation with no overlap window. The endpoint verifies against exactly one secret pulled from config. The moment you swap it, every delivery already signed with the previous key fails. The sender is behaving correctly; your verifier simply has no memory of the key it was using thirty seconds ago.
- No dual-secret grace period. Even teams that know rotation is coming often flip the value in a single deploy. Without a bounded window where both the outgoing and incoming secrets are accepted, there is no safe moment to update the sender’s copy of the key — either the sender is ahead of the verifier or behind it, and one direction always rejects.
- Cached or pinned old key. The new secret is in the vault, but a per-process cache, a warmed introspection layer, or a stale worker still holds the old value. Some replicas verify with A, some with B, and deliveries fail non-deterministically depending on which pod answers — a rotation that looks half-applied for minutes.
- Verifying only one secret when the sender supports several. Stripe can sign a single delivery so it validates against more than one endpoint secret, and most HMAC schemes let you publish a key set. If your handler passes only the first secret to
construct_event— or compares against one digest — you throw away the exact mechanism the sender gives you to rotate without downtime.
Symptom-to-Resolution Matrix Link to this section
Single-secret cutover with no overlap Link to this section
Symptoms
- A sharp
401spike that begins the instant the rotation deploy goes live and decays as the sender exhausts its retries. - Logs show
signature mismatchfor deliveries whose IDs the sender’s dashboard still lists as pending retry.
Root cause. Verification is bound to one live secret, so previous-key traffic in flight during the swap is rejected.
Fix
- Change the verifier to accept a set of secrets, not a scalar: load
WEBHOOK_SIGNING_SECRETSas a comma-separated list and try each in turn. - Verify by attempting
stripe.Webhook.construct_event(payload, sig_header, secret)against each secret and accepting on the first that succeeds. - Treat a delivery as forged only after every secret in the active set fails — then, and only then, return
401.
No dual-secret grace period Link to this section
Symptoms
- Rotation succeeds in staging with one sender but drops events in production where multiple integrations post to the same endpoint.
- You cannot find a deploy order that avoids a gap: updating the sender first rejects, updating the verifier first also rejects.
Root cause. Rotation was modeled as an atomic swap instead of an overlap. There is no interval where both keys are valid, so the sender and verifier can never be updated independently.
Fix
- Add the new secret to the active set alongside the old one and deploy the verifier first — now it accepts both.
- Update every sender to sign with the new secret at your own pace, inside the grace window.
- After the window closes and traffic on the old key reaches zero, remove the old secret from the set and deploy again.
Cached or pinned old key Link to this section
Symptoms
- The 401 rate is partial and flapping, not total — a fraction of replicas reject while others accept.
- Restarting a pod “fixes” it for that pod only.
Root cause. A stale per-process or in-memory cache is still serving the old secret after the vault was updated.
Fix
- Read the secret set from a single source of truth on each verification, or give any cache a short TTL bounded well under the sender’s retry window.
- On rotation, force cache invalidation across all replicas rather than waiting for organic expiry.
- Add the resolved secret fingerprint (a short hash prefix, never the secret) to logs so you can see which key each pod is actually using.
Verifying only one secret Link to this section
Symptoms
- The sender’s dashboard shows the delivery signed against multiple endpoint secrets, yet your handler rejects it.
- Adding a second endpoint secret upstream has no effect on your accept rate.
Root cause. The handler passes a single secret to the verifier and discards the sender’s built-in multi-secret rotation support.
Fix
- Enumerate the full active secret set on every request and pass each to the verifier until one validates.
- Use a constant-time comparison for any hand-rolled HMAC path so the multi-secret loop does not leak timing.
- Cap the set size (typically two: current plus previous) so verification cost stays bounded.
Minimal Working Implementation Link to this section
A single self-contained verifier. It loads an ordered secret set, tries Stripe’s construct_event against each secret, and — for non-Stripe HMAC senders — falls back to a constant-time digest compare against the same set. The verify_signature function returns the parsed event on the first secret that validates and raises only when all of them fail, which is exactly the property that makes an overlap-window rotation lossless. The verification block proves that an old-key signature and a new-key signature both pass while both keys sit in the active set. In stripe-python v5+, stripe.SignatureVerificationError is the canonical exception path.
import os
import hmac
import hashlib
import time
from typing import Optional
import stripe
# --- Configuration --------------------------------------------------------
# Ordered active set: current first, previous second. During a rotation both
# are present; after the grace window the previous entry is removed.
_RAW_SECRETS = os.getenv("WEBHOOK_SIGNING_SECRETS", "whsec_current,whsec_previous")
ACTIVE_SECRETS = [s.strip() for s in _RAW_SECRETS.split(",") if s.strip()]
TOLERANCE_SECONDS = 300 # reject stale timestamps, independent of key rotation
class SignatureRejected(Exception):
"""Raised only after every secret in the active set fails."""
def verify_stripe(payload: bytes, sig_header: str) -> dict:
"""Try each active secret; accept on the first that validates."""
last_error: Optional[Exception] = None
for secret in ACTIVE_SECRETS:
try:
event = stripe.Webhook.construct_event(payload, sig_header, secret)
return dict(event)
except stripe.SignatureVerificationError as exc:
last_error = exc # this key failed; try the next one
raise SignatureRejected(f"no active secret verified the signature: {last_error}")
def _expected_hmac(payload: bytes, secret: str, timestamp: str) -> str:
signed = f"{timestamp}.".encode() + payload
return hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
def verify_hmac(payload: bytes, timestamp: str, signature: str) -> bool:
"""Generic HMAC path for non-Stripe senders, constant-time over the set."""
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
raise SignatureRejected("timestamp outside tolerance window")
for secret in ACTIVE_SECRETS:
expected = _expected_hmac(payload, secret, timestamp)
if hmac.compare_digest(expected, signature): # constant-time compare
return True
raise SignatureRejected("no active secret matched the HMAC digest")
# --- Verification: prove both keys validate while both are active ----------
if __name__ == "__main__":
body = b'{"id":"evt_test","type":"checkout.session.completed"}'
ts = str(int(time.time()))
current, previous = ACTIVE_SECRETS[0], ACTIVE_SECRETS[1]
# A sender still on the previous key and one on the current key both pass.
sig_prev = _expected_hmac(body, previous, ts)
sig_curr = _expected_hmac(body, current, ts)
assert verify_hmac(body, ts, sig_prev) is True # old key still accepted
assert verify_hmac(body, ts, sig_curr) is True # new key accepted
# A signature from a retired/forged key is rejected by the whole set.
forged = _expected_hmac(body, "whsec_retired", ts)
try:
verify_hmac(body, ts, forged)
raise SystemExit("FAIL: forged signature was accepted")
except SignatureRejected:
pass
print("OK: overlap window accepts both active keys, rejects retired key")
The assert block is the regression test for the rotation itself: as long as the previous secret remains in ACTIVE_SECRETS, a delivery signed with either key verifies, so you can update senders on their own schedule and still reject anything signed with a key that has actually been retired.
Memory & Performance Constraints Link to this section
Verification stays cheap because the active set is tiny by design; the cost you must bound is the per-secret HMAC loop, not memory.
| Component | Constraint | Mitigation |
|---|---|---|
| Active secret set | Verification cost scales linearly with set size | Keep the set to current + previous (2 entries); never let old keys accumulate |
| Secret cache TTL | A long cache TTL delays rotation propagation across replicas | Bound the TTL well under the sender’s retry window; force invalidation on rotation |
| HMAC compute per request | SHA-256 over the raw body per secret holds the GIL briefly | Order the set current-first so the common case validates on the first try |
| Timestamp tolerance | Too wide a window admits replay; too tight rejects on clock skew | Keep TOLERANCE_SECONDS at 300 and enforce NTP sync independently of rotation |
| Log fingerprints | Logging full secrets to trace rotation leaks the key | Log only a short hash prefix of the matched secret, never the value |
Incident Triage & Rollback Link to this section
Fast path when the post-rotation 401 alarm fires — target under fifteen minutes, and note that rollback here means re-adding a key, not reverting code.
- Confirm it is rotation, not a forgery wave. Correlate the 401 spike start time against your rotation deploy:
kubectl rollout history deploy/webhook-ingress. A spike that begins exactly at the deploy is a cutover gap, not an attack. - Check which keys the fleet is using. Grep the resolved fingerprint you log per request:
kubectl logs -l app=webhook-ingress --since=5m | grep 'secret_fp' | sort | uniq -c. Mixed fingerprints confirm a stale-cache split-brain. - Re-widen the active set immediately. Put the previous secret back alongside the new one and redeploy so both keys verify again:
kubectl set env deploy/webhook-ingress \
WEBHOOK_SIGNING_SECRETS="whsec_new,whsec_old" && \
kubectl rollout status deploy/webhook-ingress
- Ask the sender to redeliver the lost window. In the Stripe dashboard, resend failed deliveries in the affected minutes; for generic senders, replay from their outbound log. Because verification now accepts both keys, every redelivery validates.
Rollback. The safe rollback is never “remove the new key” — it is “ensure both keys are present,” which can only add accept coverage. Retire the old key only after grep 'secret_fp' | grep old | wc -l reports zero old-key traffic across a full retry window.
Post-rollback validation. Confirm the reject rate is back to baseline and no key is split-brained:
# expect near-zero 401s attributable to signatures:
kubectl logs -l app=webhook-ingress --since=10m | grep -c 'no active secret'
Frequently Asked Questions Link to this section
How long should the overlap window be? At least as long as the sender’s full retry budget for a single delivery, plus your slowest deploy-and-drain time. For Stripe, whose retries span up to three days, a multi-day window is normal — keep both secrets active until old-key traffic reaches zero, then retire the previous key in a second deploy. The window costs almost nothing because the active set is only two entries.
Does trying multiple secrets weaken security or leak timing?
No, provided the set stays small and the comparison is constant-time. stripe.Webhook.construct_event already uses a constant-time digest check, and the generic path uses hmac.compare_digest, so iterating two secrets adds one bounded HMAC computation, not a timing oracle. A forged signature still fails against every key and returns 401.
Can I rotate without any overlap if I control both ends? Only if you can atomically update sender and verifier in the same instant with zero in-flight deliveries, which is never true under real traffic. Even a self-signed internal sender has requests on the wire during the swap. The overlap window is what makes the two ends independently deployable, so use it even when you own both sides.
Related Link to this section
- Security Boundary Configuration — the parent stage whose HMAC verification and deny-by-default contract this rotation-safe verifier plugs into without weakening it.
- Payment Webhook Handling — the downstream stage that reconciles the underlying payment events once a signature, old key or new, has been accepted.
- Configuring mTLS Between Registration and Print Services — a sibling boundary guide that rotates certificates instead of shared secrets, with the same overlap-window discipline.
- Hardening the Print Segment With IP Allowlists and VLANs — a sibling guide that controls who can reach the endpoint, complementary to controlling how a request is authenticated.