Back to Blog
securitywebhookssigningkey-managementreliability

Webhook Secret Versioning: Running Multiple Active Signing Keys Without Breaking Consumers

Rotating webhook signing secrets is painful when consumers can only hold one key at a time. Secret versioning lets you run multiple valid keys simultaneously, making rotations gradual and zero-downtime by design.

S
Sofia Andreou
Product Manager
April 22, 2026
9 min read

Webhook secret rotation gets a lot of engineering attention. What gets less attention is what happens during the window between issuing a new secret and your customers deploying code that uses it. In that gap — which can be hours or days for a large enterprise customer — you have a problem: events signed with the new key will fail verification on the consumer side.

The standard mitigation is "send both signatures simultaneously, one per key." But this is awkward to communicate, awkward to implement consistently, and doesn't scale when you're operating webhook infrastructure for hundreds of customers with different deployment cadences.

Secret versioning is the cleaner answer. Instead of treating signing secrets as a single active credential, you maintain a versioned keyring where multiple keys can be valid simultaneously. Consumers verify against all current versions in order. Old keys expire on a schedule. You get smooth, gradual rotation without a hard cutover — and without a support ticket storm.


The Core Model

A versioned signing secret has three fields beyond the key material itself:

FieldTypePurpose
versioninteger or stringMonotonically increasing identifier
active_fromtimestampWhen this key becomes valid for signing
expires_attimestampWhen this key stops being accepted for verification
is_primarybooleanWhich key to use when signing new events

At any point in time, a destination has exactly one primary key (used for signing outbound events) and zero or more secondary keys (accepted for verification but not used for new signatures). Secondary keys are old primaries that haven't expired yet.

json
{
  "destination_id": "dst_01HWXYZ...",
  "signing_keys": [
    {
      "version": 3,
      "key_prefix": "whsec_v3_...",
      "active_from": "2026-04-15T00:00:00Z",
      "expires_at": null,
      "is_primary": true
    },
    {
      "version": 2,
      "key_prefix": "whsec_v2_...",
      "active_from": "2026-03-01T00:00:00Z",
      "expires_at": "2026-05-01T00:00:00Z",
      "is_primary": false
    }
  ]
}

Both keys are currently valid for verification. Version 3 is used to sign new events. Version 2 will stop being accepted on May 1st — giving consumers six weeks to update.


Signing: Always Use the Primary Key

Signing is the easy part. When your delivery worker sends an event, it looks up the primary key for that destination and uses it. One key, one signature, no ambiguity.

go
type SigningKey struct {
    Version   int
    Secret    []byte
    IsPrimary bool
    ExpiresAt *time.Time
}

func (kr *KeyRing) PrimaryKey() (*SigningKey, error) {
    for _, k := range kr.Keys {
        if k.IsPrimary {
            return &k, nil
        }
    }
    return nil, fmt.Errorf("no primary signing key configured")
}

func SignPayload(payload []byte, key *SigningKey) string {
    timestamp := time.Now().Unix()
    mac := hmac.New(sha256.New, key.Secret)
    fmt.Fprintf(mac, "%d.%s", timestamp, payload)
    sig := hex.EncodeToString(mac.Sum(nil))
    return fmt.Sprintf("t=%d,v%d=%s", timestamp, key.Version, sig)
}

Notice the signature format: t=<unix>,v<version>=<hex>. Including the version number in the signature header is critical — it tells the consumer which key to use for verification without requiring them to try all active keys blindly.

The Stripe-compatible format uses v1= unconditionally. If you're building your own gateway, using v<N>= gives consumers a precise lookup instead of a linear scan.


Verification: Try the Indicated Version First, Fall Back to All Active Keys

On the consumer side, verification has two strategies depending on how much you trust the version tag in the header.

Strategy 1: Strict version lookup (preferred)

Parse the version from the signature header, look up exactly that key, verify. If the key doesn't exist or is expired, reject.

go
func VerifySignature(payload []byte, sigHeader string, keyring map[int][]byte) error {
    // Parse: "t=1713830400,v3=abc123def456..."
    parts := parseSignatureHeader(sigHeader)
    ts, err := strconv.ParseInt(parts["t"], 10, 64)
    if err != nil {
        return fmt.Errorf("invalid timestamp in signature")
    }

    // Reject replays older than 5 minutes
    if time.Since(time.Unix(ts, 0)) > 5*time.Minute {
        return fmt.Errorf("signature timestamp too old")
    }

    // Find the versioned signature (v1, v2, v3, ...)
    var version int
    var receivedSig string
    for k, v := range parts {
        if strings.HasPrefix(k, "v") {
            n, err := strconv.Atoi(strings.TrimPrefix(k, "v"))
            if err == nil {
                version = n
                receivedSig = v
            }
        }
    }

    secret, ok := keyring[version]
    if !ok {
        return fmt.Errorf("unknown key version: %d", version)
    }

    mac := hmac.New(sha256.New, secret)
    fmt.Fprintf(mac, "%d.%s", ts, payload)
    expected := hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(receivedSig), []byte(expected)) {
        return fmt.Errorf("signature mismatch")
    }
    return nil
}

Strategy 2: Try all active keys (fallback for legacy consumers)

For consumers that received events before you introduced versioning, you may have sent signatures without version tags. In that case, try each active key until one matches:

go
func VerifySignatureFallback(payload []byte, sigHeader string, keys [][]byte) error {
    parts := parseSignatureHeader(sigHeader)
    ts, _ := strconv.ParseInt(parts["t"], 10, 64)
    receivedSig := parts["v1"] // legacy unversioned format

    if time.Since(time.Unix(ts, 0)) > 5*time.Minute {
        return fmt.Errorf("signature timestamp too old")
    }

    toSign := fmt.Sprintf("%d.%s", ts, payload)
    for _, secret := range keys {
        mac := hmac.New(sha256.New, secret)
        mac.Write([]byte(toSign))
        expected := hex.EncodeToString(mac.Sum(nil))
        if hmac.Equal([]byte(receivedSig), []byte(expected)) {
            return nil
        }
    }
    return fmt.Errorf("no valid key found for signature")
}

The fallback approach works but has a subtle security property: you can't distinguish "wrong key" from "tampered payload" without also logging which key matched. Prefer the strict version lookup in new integrations.


Schema Design for a Versioned Keyring

If you're building the server side — the component that issues and rotates keys — you need a table that supports multiple active keys per destination.

sql
CREATE TABLE destination_signing_keys (
    id              UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    destination_id  UUID        NOT NULL REFERENCES destinations(id) ON DELETE CASCADE,
    version         INTEGER     NOT NULL,
    secret_ciphertext TEXT      NOT NULL,          -- encrypted at rest
    is_primary      BOOLEAN     NOT NULL DEFAULT FALSE,
    active_from     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at      TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (destination_id, version)
);

-- Fetch all active keys for a destination (primary first)
CREATE INDEX idx_dsk_active ON destination_signing_keys (destination_id, expires_at)
    WHERE expires_at IS NULL OR expires_at > NOW();

The query to load a destination's active keyring at delivery time:

sql
SELECT version, secret_ciphertext, is_primary
FROM destination_signing_keys
WHERE destination_id = $1
  AND active_from <= NOW()
  AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY is_primary DESC, version DESC;

ORDER BY is_primary DESC puts the primary key first so your delivery code can take rows[0] directly.


The Rotation Workflow

With this model, rotation becomes a four-step process instead of a hard cutover:

Step 1: Generate a new key, add it as non-primary The new key has is_primary = false and active_from = NOW(). Both keys are now valid for verification. Nothing changes for consumers.

Step 2: Notify consumers Send a notification (email, dashboard banner, webhook) telling them a new key is available and when the old one expires. Give them the new key material and the version number.

Step 3: Promote the new key to primary Set is_primary = true on the new key, is_primary = false on the old key. New events are now signed with the new key. Consumers still verifying against the old key continue to work — they'll just see events signed with a version number they don't have. This is the signal for them to deploy.

Step 4: Expire the old key After your grace period (30 days is reasonable for most SaaS; 7 days is aggressive but acceptable for security-sensitive rotations), set expires_at = NOW() on the old key. Consumers still using the old key will now see verification failures.

StepOld Key StateNew Key StateConsumer Impact
Before rotationPrimary, no expiryDoes not existNone
After step 1Primary, no expirySecondary, no expiryNone
After step 3Secondary, expires in 30dPrimary, no expiryEvents now signed with new key — consumers need to update
After step 4ExpiredPrimaryOld key no longer accepted — consumers must be updated

The window between steps 3 and 4 is your migration runway. Size it based on your customers' deployment velocity.


What to Expose in Your API

If you're building customer-facing webhook infrastructure (your customers are the ones configuring destinations), the key management API should expose:

GET    /v1/destinations/{id}/signing-keys        — list all active versions
POST   /v1/destinations/{id}/signing-keys/rotate — generate new key, set expiry on current
DELETE /v1/destinations/{id}/signing-keys/{ver}  — immediately revoke a specific version

The rotate endpoint does steps 1–3 atomically. It should return both the old key's new expiry and the new key's version and secret (the secret is returned once, never again). The consumer gets exactly one chance to copy the key.


One-Time Secret Exposure

Signing secrets must follow the same rule as API keys: return the plaintext once at creation time, never again. Your database stores the encrypted form. The API returns the decrypted secret only in the response to the creation request.

json
{
  "version": 3,
  "secret": "whsec_v3_9f2e1a4b8c3d7f6e...",
  "active_from": "2026-04-22T00:00:00Z",
  "expires_at": null,
  "is_primary": true,
  "message": "Save this secret now. It will not be shown again."
}

Subsequent GET requests return the version metadata and a prefix (whsec_v3_9f2e...) for identification, not the full secret.


GetHook's Approach

GetHook handles signing key management per destination. When you add a destination, a signing secret is generated and returned once. When you rotate via the dashboard or API, the old key enters a configurable grace period — events delivered during that window carry the version tag so your consumers know exactly which key to verify against. Expired keys are automatically removed from the acceptance list.

This means you can give enterprise customers a 60-day rotation window and aggressive security-conscious customers a 7-day window, all managed per destination without any infrastructure changes on your end.


The Practical Upside

Secret versioning shifts webhook secret rotation from a "coordinated cutover" — which requires your customer's engineering team to be ready at a specific moment — to a "rolling migration" that consumers can pick up at any point during a multi-week window.

For SaaS products serving enterprise customers with change management processes, this difference is enormous. A hard cutover that breaks a production integration at an enterprise customer is a support escalation, a potential SLA conversation, and erosion of trust. A versioned rotation that gives six weeks of overlap is a changelog entry.

If you're building webhook delivery infrastructure and aren't using versioned signing keys yet, the schema migration is small and the protocol change is backward-compatible. It's worth doing before your first enterprise customer asks why their webhook verification broke during a routine rotation.

Get started with GetHook's signing key management →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.