InstaWebhook
September 8, 2026By InstaWebhook TeamWebhook Security

Zero-Downtime Secret Rotation: How to Manage Dual-Active Webhook Signatures

Zero-Downtime Secret Rotation: How to Manage Dual-Active Webhook Signatures In modern Cloud Native and DevOps environments, credential management is heavily regulated.

API gateway webhook verificationAPI key rotation zero downtimeAPI secret rotation 90 daysAPI security compliance standardsAPI security secret expirationautomated webhook secret rotationAWS Lambda webhook signature validationCloudflare workers webhook verificationcryptographic secret rotationDevOps secret managementdevops webhook security patternsdual active signing key windowdual active webhook signingdual secret verification algorithmdual secret webhook validationdual signing secret architectureedge computing webhook verificationedge receiver webhook securityGitHub webhook secret rotationGitHub webhook signing secretgraceful secret rotationhandling dual active secretsHMAC SHA256 webhook validationHMAC webhook secret rotationin flight webhook deliveryinfrastructure security secret rotationmultiple webhook signing keysnon breaking secret rotationpreventing dropped webhooks rotationreal time webhook signature checkrolling webhook secret updatesrotate webhook secretrotation window webhook handlingseamless webhook secret rotationsecret management devopssecure webhook endpoint handlingSOC2 secret rotation complianceStripe dual active secretsStripe webhook secret rotationStripe webhook signing secretwebhook authentication rotationwebhook listener secret rotationwebhook payload signature matchingwebhook receiver zero downtimewebhook security best practiceswebhook security compliancewebhook signature header parsingwebhook signature rotation strategywebhook signature verificationwebhook signing key lifecyclewebhooks security architecturewebhook verification headerzero downtime API migrationzero downtime deployment webhookszero downtime secret rotation
Zero Downtime Secret Rotation How To Manage Dual Active Webhook Signatures

Zero-Downtime Secret Rotation: How to Manage Dual-Active Webhook Signatures

In modern Cloud Native and DevOps environments, credential management is heavily regulated. Compliance frameworks such as SOC 2 Type II, PCI DSS 4.0, and ISO/IEC 27001 all push organizations toward regular rotation of API keys, tokens, and signing secrets. PCI DSS 4.0's Requirement 8.6.3, mandatory since March 31, 2025, requires periodic rotation of credentials used by applications and system accounts — but notably, it asks organizations to set the frequency themselves through a targeted risk analysis rather than dictating a fixed number of days. In practice, most teams still land on a 90-day cadence for high-security workloads as an industry default, tightening to 30–90 days in payment-adjacent systems and loosening to 180–365 days for lower-risk internal services.

Whatever cadence you pick, rotating a webhook signing secret with a naive "hard-cut" approach frequently causes silent data loss. If your payment processor, SaaS provider, or internal microservices emit thousands of webhook events per minute, invalidating an old secret before every one of your application instances has the new one causes signature verification failures. In-flight requests drop, payment confirmations stall, and database syncs fail.

To solve this, leading API providers — including Stripe, Kit (formerly ConvertKit), and Svix-powered platforms — rely on dual-active webhook signing. By keeping two secrets valid during a defined overlap window, receivers can validate payloads against either key without dropping a single event. This guide walks through the mechanics of zero-downtime secret rotation, compares how major providers actually implement it today, and gives you a corrected, production-ready Node.js/TypeScript receiver that handles the encoding differences between providers — a detail that trips up a lot of "generic" webhook verifiers.

The Hidden Danger of Hard-Cut Webhook Secret Rotation

To understand why dual-active signatures matter, it helps to see how HMAC-based webhook verification normally works. When a sender fires an HTTP POST to your endpoint, it generates a cryptographic signature using a shared secret and the raw request body. Your application recomputes that signature on receipt; if the hashes match, the request is trusted.

In a naive single-secret setup, rotation looks like this:

  1. You click "rotate" in your provider's dashboard (or fire off an API call).
  2. The provider immediately starts signing new events with Secret B.
  3. Your receiver — which still only has Secret A loaded — starts rejecting every incoming request with a 401 or 403.
  4. Requests stay broken until your deploy pipeline finishes rolling out Secret B.
Code example
[ Sender Service ]  --- (Signed with Secret B) --->  [ Application Receiver ]
                                                             |
                                                   (Checks against Secret A)
                                                             |
                                                  ❌ Signature Mismatch (401 Drop)

Even with fast CI/CD, this propagation gap can last anywhere from 30 seconds to several minutes across a fleet of instances. In event-driven architectures, retries during that gap can pile up in dead-letter queues and cause out-of-order processing once they're eventually replayed.

What Is Dual-Active Webhook Signing?

Dual-active signing removes the deployment race condition by introducing a transition window during which two secrets are simultaneously valid — both for signing (on the sender's side, where the provider supports it) and for verification (on your receiver).

The pattern follows a classic expand–verify–contract shape:

  • Expand: Provision a new secret alongside the active one. The sender starts signing with the new secret, or dual-signs with both.
  • Verify: Your receiver loads the new secret and keeps the old one. On each incoming request, it checks the signature against every active secret and accepts the request if any one of them matches.
  • Contract: Once telemetry shows 100% of traffic verifying against the new secret — or the provider's grace window closes — the old secret is retired and removed from your receiver's config.
Code example
                ┌──────────────────────────────────────────┐
                │           Dual-Active Window              │
                │  Old Secret (A) + New Secret (B) Active   │
                └────────────────────┬─────────────────────┘
                                     │
           ┌─────────────────────────┴─────────────────────────┐
           ▼                                                   ▼
[ Incoming Webhook (Sig A) ]                         [ Incoming Webhook (Sig B) ]
           │                                                   │
           ├─► Check Secret B ❌                               ├─► Check Secret B ✅ (Match!)
           └─► Fallback Secret A ✅ (Match!)                   └─► Done

How Stripe Actually Handles Webhook Secret Rotation

Stripe is the industry reference point for this pattern, but the mechanics are worth getting precise about, since it's easy to overstate how much is automatable.

Rotation is a Dashboard (Workbench) action, not a public REST endpoint. As of today, Stripe's /v1/webhook_endpoints API only exposes create, update, retrieve, list, and delete operations — there is no documented rotate_secret API call. To roll a secret, you open the endpoint in Workbench's Webhooks tab, use the overflow menu, and click Roll secret. From there you choose to either expire the old secret immediately or delay expiration by up to 24 hours, during which both secrets remain active and Stripe signs each outgoing event with both.

The Stripe-Signature Header

Code example
Stripe-Signature: t=1725800000,v1=5257a869e7eceedd9a051200457f6009b11b196f,v1=6d83b5b630e238122c4f1c713bc30e32f05a9611
  • t= is the Unix timestamp the event was signed at.
  • Each v1= value is a separate hex-encoded HMAC-SHA256 signature — one per active secret.
  • The signed payload is the literal string {timestamp}.{raw_body}, not the raw body alone.

Official Stripe SDKs (stripe.webhooks.constructEvent) accept an array of secrets and consider the payload valid if any of the v1= values matches any of the secrets you supply — which is exactly the dual-active pattern.

Comparing Rotation Support Across Major Providers

Different vendors implement this differently enough that a single "generic HMAC checker" quietly breaks on at least two of the four providers below if you're not careful about encoding.

ProviderRotation MechanismHeader FormatSignature EncodingDual Signatures?
StripeDashboard-only "Roll secret," delay up to 24hStripe-Signature: t=...,v1=sig1,v1=sig2HexYes — multiple v1= entries
GitHubManual: update the secret field, receiver holds an active-secret list during the transitionX-Hub-Signature-256: sha256=<hex>HexNo — sender emits one signature; overlap is entirely receiver-side
ShopifyClient secret rotation via Dev Dashboard; up to ~1 hour propagation delay for the new secret to take effectX-Shopify-Hmac-Sha256: <base64>Base64No — single bare signature, no key=value wrapper
Svix / Standard WebhooksProvider-driven rollover window (commonly API-triggered, duration varies by platform)webhook-signature: v1,sig1 v1,sig2 (space-delimited)Base64Yes — multiple v1, entries
Kit (ConvertKit)API-triggered: POST /rotate, tracks previous_secret_expires_at, returns 409 if a rotation is already in progressX-Kit-Signature: v1=sig1,v1=sig2HexYes

Two corrections worth flagging if you've seen this table before: Shopify's signature is base64, not hex, and it's a single value with no key=value wrapper at all — the entire header content is the signature. GitHub does not dual-sign on its own; whatever overlap protection you get for GitHub webhooks, you build entirely in your own receiver by accepting both the old and new secret while you update GitHub's dashboard field, then dropping the old one once the new one is confirmed live.

The Standard Webhooks Specification

The "Svix" row above deserves its own explanation, because it's not just Svix's convention — it's the basis of the open Standard Webhooks specification that a growing number of platforms (including several sending through Svix's infrastructure) have converged on. It defines three headers instead of one combined header:

  • webhook-id — a unique message identifier
  • webhook-timestamp — Unix timestamp
  • webhook-signature — one or more v1,<base64-signature> entries, space-delimited

The signed string is constructed as {id}.{timestamp}.{raw_body}, HMAC-SHA256'd and base64-encoded — a different construction from Stripe's {timestamp}.{raw_body} and a different encoding from Stripe/GitHub's hex output. Verification should also enforce a timestamp tolerance (5 minutes is the common default) to mitigate replay, and — critically — must check every v1, entry in the header, not just the first, or you'll intermittently reject valid deliveries during a provider's rotation window.

Implementing Dual-Active Signature Verification, Correctly

Signature verification should sit as close to the edge as practical (an API gateway, a reverse proxy, or a thin Node.js layer in front of your core services) so a bad signature never touches application logic.

Two non-negotiables, regardless of provider:

  1. Verify against the raw, unparsed body. Parsing JSON first and re-serializing it changes whitespace and key order, which silently breaks the signature.
  2. Use crypto.timingSafeEqual for comparisons — never === or == — to avoid timing side-channel attacks.

A single hardcoded "hex, key=value header" verifier — the shape many tutorials use — works for Stripe and GitHub but silently fails for Shopify and Svix/Standard Webhooks, because they're base64-encoded and formatted differently. The implementation below abstracts the per-provider differences (encoding, header shape, and the exact string that was signed) behind a small config table, so the verification loop itself stays provider-agnostic:

Code example
import crypto from 'node:crypto';

type Encoding = 'hex' | 'base64';

interface ProviderConfig {
  name: string;
  encoding: Encoding;
  parseHeader: (headerValue: string) => { timestamp?: number; signatures: string[] };
  buildSignedPayload: (rawBody: string, ctx: { timestamp?: number; headers: Record<string, string> }) => string;
}

const PROVIDERS: Record<string, ProviderConfig> = {
  stripe: {
    name: 'Stripe',
    encoding: 'hex',
    parseHeader(headerValue) {
      let timestamp: number | undefined;
      const signatures: string[] = [];
      for (const part of headerValue.split(',')) {
        const [key, value] = part.trim().split('=');
        if (key === 't') timestamp = parseInt(value, 10);
        else if (key === 'v1') signatures.push(value);
      }
      return { timestamp, signatures };
    },
    buildSignedPayload: (rawBody, ctx) => `${ctx.timestamp}.${rawBody}`,
  },

  github: {
    name: 'GitHub',
    encoding: 'hex',
    parseHeader(headerValue) {
      const [, hash] = headerValue.split('=');
      return { signatures: hash ? [hash] : [] };
    },
    buildSignedPayload: (rawBody) => rawBody,
  },

  shopify: {
    name: 'Shopify',
    encoding: 'base64',
    // Shopify sends a single bare base64 string — no "key=value" wrapper,
    // and no built-in dual-signature support.
    parseHeader: (headerValue) => ({ signatures: [headerValue.trim()] }),
    buildSignedPayload: (rawBody) => rawBody,
  },

  standardWebhooks: {
    name: 'Standard Webhooks / Svix',
    encoding: 'base64',
    parseHeader(headerValue) {
      const signatures = headerValue
        .split(' ')
        .map((s) => s.trim().split(',')[1])
        .filter(Boolean) as string[];
      return { signatures };
    },
    buildSignedPayload: (rawBody, ctx) =>
      `${ctx.headers['webhook-id']}.${ctx.headers['webhook-timestamp']}.${rawBody}`,
  },
};

interface VerificationResult {
  isValid: boolean;
  matchedSecretIndex?: number;
  matchedProviderName?: string;
  error?: string;
}

/**
 * Verifies an incoming webhook signature against a list of currently active secrets,
 * supporting dual-active rotation windows for the given provider.
 */
export function verifyWebhookSignature(
  providerKey: keyof typeof PROVIDERS,
  rawBody: string | Buffer,
  signatureHeader: string,
  activeSecrets: string[],
  allHeaders: Record<string, string> = {},
  toleranceSeconds = 300
): VerificationResult {
  const provider = PROVIDERS[providerKey];
  if (!provider) return { isValid: false, error: `Unknown provider: ${String(providerKey)}` };
  if (!signatureHeader) return { isValid: false, error: 'Missing signature header' };
  if (!activeSecrets.length) return { isValid: false, error: 'No active secrets configured' };

  const body = rawBody.toString('utf-8');
  const { timestamp, signatures } = provider.parseHeader(signatureHeader);

  if (timestamp !== undefined) {
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - timestamp) > toleranceSeconds) {
      return { isValid: false, error: 'Timestamp outside tolerance window (possible replay)' };
    }
  }
  if (!signatures.length) {
    return { isValid: false, error: 'No signatures found in header' };
  }

  const payload = provider.buildSignedPayload(body, { timestamp, headers: allHeaders });

  for (let i = 0; i < activeSecrets.length; i++) {
    const rawDigest = crypto.createHmac('sha256', activeSecrets[i]).update(payload).digest();

    for (const sig of signatures) {
      try {
        const sigBuf = Buffer.from(sig, provider.encoding);
        if (sigBuf.length === rawDigest.length && crypto.timingSafeEqual(sigBuf, rawDigest)) {
          return { isValid: true, matchedSecretIndex: i, matchedProviderName: provider.name };
        }
      } catch {
        // Malformed signature segment — skip it and keep checking the others.
      }
    }
  }

  return { isValid: false, error: 'Signature verification failed against all active secrets' };
}

Note the comparison step: rather than re-encoding the computed digest to a string and comparing strings, it decodes the incoming signature back into raw bytes (Buffer.from(sig, provider.encoding)) and compares those bytes directly against the raw HMAC output. That one change is what makes the same function correct for both hex-based providers (Stripe, GitHub) and base64-based ones (Shopify, Standard Webhooks) without duplicating the verification logic per provider.

Usage Example in Express.js

Code example
import express from 'express';
import { verifyWebhookSignature } from './webhookVerifier';

const app = express();

const WEBHOOK_SECRETS = [
  process.env.WEBHOOK_SECRET_PRIMARY,
  process.env.WEBHOOK_SECRET_SECONDARY,
].filter(Boolean) as string[];

// express.raw() is required to keep the exact byte stream for HMAC verification.
app.post('/api/v1/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
  const result = verifyWebhookSignature(
    'stripe',
    req.body,
    req.headers['stripe-signature'] as string,
    WEBHOOK_SECRETS
  );

  if (!result.isValid) {
    console.warn(`[Webhook Auth Failure] ${result.error}`);
    return res.status(400).send(`Webhook Error: ${result.error}`);
  }

  if (result.matchedSecretIndex === 1) {
    console.info('[Telemetry] Verified using the secondary (legacy) secret — rotation in progress.');
  }

  return res.status(200).json({ received: true });
});

app.post('/api/v1/webhooks/shopify', express.raw({ type: 'application/json' }), (req, res) => {
  const result = verifyWebhookSignature(
    'shopify',
    req.body,
    req.headers['x-shopify-hmac-sha256'] as string,
    WEBHOOK_SECRETS
  );

  if (!result.isValid) {
    return res.status(401).send(`Unauthorized: ${result.error}`);
  }
  return res.status(200).send('OK');
});

A Realistic Rotation Workflow

Code example
┌────────────────────────────────────────────────────────────────────────┐
│                        Secret Rotation Lifecycle                       │
├────────────────────────────────────────────────────────────────────────┤
│ 1. GENERATE  ► Provision Secret B in your secrets manager              │
│ 2. INGEST    ► Push [Secret B, Secret A] to receivers (hot-reload)     │
│ 3. ROTATE    ► Trigger rotation on the provider's side                 │
│ 4. MONITOR   ► Watch verification metrics: traffic should shift        │
│              from Secret A to Secret B                                 │
│ 5. REVOKE    ► Remove Secret A once traffic on it hits 0%              │
└────────────────────────────────────────────────────────────────────────┘
  1. Provision the new secret. Generate a high-entropy value (32 random bytes, base64url-encoded is a common convention) and store it in your secrets manager, shifting the old value to a "secondary" slot.
  2. Deploy the receiver first. Get both secrets live in your receiving services — via a hot-reloadable config store (AWS AppConfig, a Kubernetes ConfigMap, Redis) if you want to avoid a full redeploy — before you touch the provider's setting.
  3. Trigger rotation on the provider. This step differs meaningfully by vendor:
    • Stripe: manual — "Roll secret" in Workbench, with a delayed-expiration option.
    • Kit and similar API-first providers: a POST /rotate call that returns the new secret once and tracks an expiry timestamp for the old one.
    • GitHub: update the secret field in the webhook settings; there is no server-side overlap, so your receiver's own dual-secret list is what protects you here.
    • Shopify: rotate the client secret in the Dev Dashboard; expect up to roughly an hour of propagation lag before all traffic uses the new secret.
  4. Monitor. Log which secret validated each request and alert if verification failures rise above a small threshold (0.1% is a reasonable starting bar) after a rotation.
  5. Revoke and clean up. Once traffic against the old secret drops to zero and any provider-side grace window has closed, delete the old secret from both the provider and your receiver's config.

Operational Best Practices Checklist

  • Constant-time comparison, always. Never use ==/=== on signatures or digests.
  • Match encoding to the provider. Hex for Stripe and GitHub; base64 for Shopify and Standard Webhooks. Mixing these up is the most common cause of "it works for Stripe but not Shopify" bugs.
  • Keep grace windows short and bounded. A day or less is typical; leaving two secrets valid indefinitely defeats the point of rotating at all.
  • Preserve the raw request body. Confirm your proxies, gateways, and body-parsing middleware don't re-encode or reformat it before you compute a signature.
  • Alert on verification-failure spikes, not just on hard outages — a slow climb in 401s after a rotation is often the first sign something's misconfigured.
  • Automate rotation through your secrets manager, not copy-paste in a dashboard, wherever the provider's API supports it (Stripe currently doesn't; several others, like Kit, do).
  • Size your rotation cadence to a documented risk analysis rather than assuming a blanket "every provider requires 90 days" rule — PCI DSS 4.0 explicitly asks for risk-based intervals, not a fixed number.

Summary

Rotating signing secrets regularly is good hygiene and, for many organizations, a compliance requirement — but a hard cutover is the wrong way to do it. Dual-active webhook signing — two valid secrets during a bounded overlap window, with a receiver that checks incoming signatures against all of them — is how Stripe, Kit, Svix-based platforms, and (in a more manual, receiver-driven form) GitHub and Shopify avoid dropping events during rotation. The catch is that "dual-active" isn't one universal recipe: the header shape, the exact string that gets signed, and even the byte encoding of the signature itself differ enough between providers that a verifier built and tested against one of them can quietly fail against another. Build your verification layer provider-aware from the start, and rotation becomes a routine maintenance task instead of an incident.


Sources