Skip to content

Webhook Secret Rotation — Complete Guide

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about Webhook Secret Rotation. We cover key concepts, practical examples, and best practices to help you master this topic.

Learn webhook secret rotation: rotate HMAC signing secrets without breaking integrations, implement dual-secret periods, automate key rotation, and handle emergency secret revocation.

What You Learn

You will learn how to rotate webhook signing secrets safely, implement dual-secret periods where old and new secrets are valid simultaneously, automate rotation on a schedule, and handle emergency revocation when a secret is compromised.

Why It Matters

Webhook secrets protect payload authenticity. A compromised secret lets attackers send fake webhooks. Regular rotation limits the damage window of a compromised secret. Without rotation, a leaked secret remains valid indefinitely.

Real-World Use

DodaTech rotates webhook secrets every 90 days. The rotation system supports a 7-day overlap period where both old and new secrets are valid. Zero webhook deliveries are lost during rotation. In 2025, emergency rotation was triggered twice due to suspected compromise.

Rotation Strategy

graph LR
    subgraph Week 1-12
        Secret1[Secret v1 active]
    end
    subgraph Week 13 (Rotation)
        Secret2[Secret v2 created]
        Overlap[Both v1 and v2 valid]
    end
    subgraph Week 14+
        Secret2
    end
    subgraph Emergency
        Compromise[Secret compromised]
        Revoke[Old secret revoked]
        New[New secret created]
    end

The standard rotation creates a new secret, enters an overlap period where both are valid, then retires the old secret. Emergency rotation revokes the compromised secret immediately.

Secret Manager

class WebhookSecretManager {
    constructor(options = {}) {
        this.rotationIntervalDays = options.rotationIntervalDays || 90;
        this.overlapDays = options.overlapDays || 7;
        this.secrets = new Map(); // version -> { secret, createdAt, status }
        this.currentVersion = 1;
        this.rotationTimer = null;
    }

    async initialize() {
        // Load secrets from secure storage
        const stored = await this.loadFromVault();
        if (stored.length > 0) {
            for (const s of stored) {
                this.secrets.set(s.version, s);
            }
            this.currentVersion = Math.max(...stored.map(s => s.version));
        } else {
            // Generate initial secret
            await this.generateNewSecret();
        }

        // Schedule rotation
        this.scheduleRotation();
    }

    async generateNewSecret() {
        const newVersion = this.currentVersion + 1;
        const secret = crypto.randomBytes(32).toString('hex');

        const secretEntry = {
            version: newVersion,
            secret,
            createdAt: new Date().toISOString(),
            status: 'active',
            expiresAt: new Date(
                Date.now() + this.rotationIntervalDays * 86400000
            ).toISOString(),
        };

        this.secrets.set(newVersion, secretEntry);
        await this.storeInVault(newVersion, secret);

        // If we had a previous active secret, move it to overlap
        const previousActive = this.getActiveSecret();
        if (previousActive && previousActive.version !== newVersion) {
            previousActive.status = 'overlap';
            previousActive.overlapUntil = new Date(
                Date.now() + this.overlapDays * 86400000
            ).toISOString();
        }

        this.currentVersion = newVersion;
        console.log(`Secret v${newVersion} generated, expires ${secretEntry.expiresAt}`);

        return secretEntry;
    }

    getActiveSecret() {
        for (const [version, entry] of this.secrets) {
            if (entry.status === 'active') return entry;
        }
        return null;
    }

    getAllValidSecrets() {
        const valid = [];
        for (const [version, entry] of this.secrets) {
            if (entry.status === 'active' || entry.status === 'overlap') {
                valid.push(entry);
            }
        }
        return valid;
    }

    verifySignature(rawBody, signatureHeader) {
        const validSecrets = this.getAllValidSecrets();

        for (const entry of validSecrets) {
            const expectedSig = signatureHeader.replace('sha256=', '');
            const computedSig = crypto
                .createHmac('sha256', entry.secret)
                .update(rawBody)
                .digest('hex');

            if (crypto.timingSafeEqual(
                Buffer.from(computedSig),
                Buffer.from(expectedSig)
            )) {
                return { valid: true, version: entry.version };
            }
        }

        return { valid: false };
    }

    async rotate() {
        console.log('Starting secret rotation...');
        await this.generateNewSecret();
        this.scheduleRotation();
    }

    async emergencyRotate() {
        console.log('EMERGENCY: Rotating all secrets');

        // Revoke all existing secrets
        for (const [version, entry] of this.secrets) {
            entry.status = 'revoked';
            entry.revokedAt = new Date().toISOString();
            entry.revocationReason = 'emergency';
        }

        // Generate new secret
        await this.generateNewSecret();
    }

    scheduleRotation() {
        if (this.rotationTimer) clearTimeout(this.rotationTimer);

        const msUntilRotation = this.rotationIntervalDays * 86400000;
        this.rotationTimer = setTimeout(() => this.rotate(), msUntilRotation);

        console.log(`Next rotation in ${this.rotationIntervalDays} days`);
    }

    cleanup() {
        // Remove expired overlap and revoked secrets
        const now = Date.now();
        for (const [version, entry] of this.secrets) {
            if (entry.status === 'overlap' && now > new Date(entry.overlapUntil).getTime()) {
                this.secrets.delete(version);
                console.log(`Secret v${version} expired and removed`);
            }
            if (entry.status === 'revoked' &&
                now > new Date(entry.revokedAt).getTime() + 7 * 86400000) {
                this.secrets.delete(version);
            }
        }
    }
}

Expected output: Secret manager maintains multiple secret versions. Active secret signs new webhooks. All valid secrets (active + overlap) can verify incoming signatures. Rotation creates new versions automatically.

Consumer-Side Secret Handling

// Consumer handles rotated secrets
class WebhookConsumerSecretManager {
    constructor() {
        this.secrets = new Map(); // keyId -> secret
        this.currentKeyId = null;
    }

    async fetchSecrets() {
        // Fetch current secrets from provider's API
        const response = await fetch('https://api.provider.com/webhook-secrets', {
            headers: { Authorization: `Bearer ${this.apiToken}` },
        });

        const { secrets } = await response.json();

        for (const s of secrets) {
            this.secrets.set(s.keyId, s.secret);
        }

        this.currentKeyId = secrets[0]?.keyId;
    }

    verifyWithRotation(rawBody, signatureHeader) {
        // The signature header may contain multiple signatures
        // for different key versions
        const signatures = this.parseSignatureHeader(signatureHeader);

        for (const { keyId, signature } of signatures) {
            const secret = this.secrets.get(keyId);
            if (!secret) continue;

            const expectedSig = signature.replace('sha256=', '');
            const computedSig = crypto
                .createHmac('sha256', secret)
                .update(rawBody)
                .digest('hex');

            if (crypto.timingSafeEqual(
                Buffer.from(computedSig),
                Buffer.from(expectedSig)
            )) {
                // If using an old secret, fetch latest
                if (keyId !== this.currentKeyId) {
                    this.fetchSecrets();
                }
                return true;
            }
        }

        return false;
    }

    parseSignatureHeader(header) {
        // Format: keyId="v1",signature="sha256=abc...",keyId="v2",signature="sha256=def..."
        const parts = header.split(',');
        const signatures = [];

        for (let i = 0; i < parts.length; i += 2) {
            const keyId = parts[i]?.split('=')[1]?.replace(/"/g, '');
            const sig = parts[i + 1]?.split('=')[1]?.replace(/"/g, '');
            if (keyId && sig) {
                signatures.push({ keyId, signature: sig });
            }
        }

        return signatures;
    }
}

Expected output: Consumer fetches current secrets from provider API. The signature header may include multiple key versions. The consumer tries each until verification succeeds. Old secrets trigger a secret refresh.

Automated Rotation Pipeline

// Automated rotation with notification
class AutomatedRotationPipeline {
    constructor(secretManager, notificationService) {
        this.secretManager = secretManager;
        this.notifier = notificationService;
    }

    async runRotation() {
        console.log('=== Automated Secret Rotation ===');

        // 1. Generate new secret
        const newSecret = await this.secretManager.generateNewSecret();

        // 2. Notify consumers of new secret
        await this.notifyConsumers(newSecret);

        // 3. Wait for overlap period
        console.log(`Overlap period: ${this.secretManager.overlapDays} days`);
        await this.waitForOverlap();

        // 4. Verify consumers have updated
        const verificationResult = await this.verifyConsumerAdoption(newSecret.version);
        if (!verificationResult.allUpdated) {
            console.warn('Some consumers have not updated:', verificationResult.pending);
        }

        // 5. Deactivate old secret
        await this.secretManager.deactivateOldSecret();

        console.log('=== Rotation Complete ===');
    }

    async notifyConsumers(newSecret) {
        const subscribers = await this.getActiveSubscribers();

        for (const subscriber of subscribers) {
            try {
                await this.notifier.send(subscriber.url, {
                    type: 'secret.rotation',
                    newVersion: newSecret.version,
                    newSecret: newSecret.secret,
                    effectiveDate: newSecret.createdAt,
                    overlapEndDate: new Date(
                        Date.now() + this.secretManager.overlapDays * 86400000
                    ).toISOString(),
                    rotationUrl: 'https://api.example.com/webhook-secrets',
                });

                console.log(`Notified ${subscriber.id}`);
            } catch (err) {
                console.error(`Failed to notify ${subscriber.id}:`, err.message);
            }
        }
    }

    async verifyConsumerAdoption(newVersion) {
        // Check delivery logs to see if consumers are
        // successfully verifying with the new secret
        const recentDeliveries = await this.getRecentDeliveries(1); // 1 hour
        const pending = [];

        for (const delivery of recentDeliveries) {
            if (delivery.status === 'failed' &&
                delivery.error?.includes('signature')) {
                pending.push(delivery.subscriberId);
            }
        }

        return {
            allUpdated: pending.length === 0,
            pending,
            totalChecked: recentDeliveries.length,
        };
    }

    async waitForOverlap() {
        const overlapMs = this.secretManager.overlapDays * 86400000;
        return new Promise(resolve => setTimeout(resolve, overlapMs));
    }
}

Expected output: Automated pipeline generates new secret, notifies all subscribers, waits through overlap period, verifies adoption, and deactivates old secret. Failed verifications are reported for manual follow-up.

Emergency Revocation

class EmergencyRevocation {
    constructor(secretManager, webhookProvider) {
        this.secretManager = secretManager;
        this.provider = webhookProvider;
    }

    async handleCompromise(compromisedVersion, reason) {
        console.log(`EMERGENCY REVOCATION: v${compromisedVersion}`);

        // 1. Revoke compromised secret immediately
        await this.secretManager.revokeSecret(compromisedVersion);

        // 2. Generate new secret
        const newSecret = await this.secretManager.generateNewSecret();

        // 3. Force all subscribers to update
        await this.forceConsumerUpdate(newSecret);

        // 4. Log security incident
        await this.logSecurityIncident({
            type: 'secret_compromise',
            compromisedVersion,
            reason,
            newVersion: newSecret.version,
            timestamp: new Date().toISOString(),
        });

        // 5. Notify security team
        await this.notifySecurityTeam(compromisedVersion, reason);

        return {
            status: 'contained',
            revokedVersion: compromisedVersion,
            newVersion: newSecret.version,
        };
    }

    async forceConsumerUpdate(newSecret) {
        const subscribers = await this.getActiveSubscribers();

        for (const subscriber of subscribers) {
            // Send urgent notification
            await this.sendUrgentNotification(subscriber, newSecret);

            // Temporarily hold deliveries if consumer doesn't update
            // Resume when consumer confirms update
            await this.holdDeliveries(subscriber.id, 3600000); // 1 hour
        }
    }

    async sendUrgentNotification(subscriber, newSecret) {
        // Use out-of-band communication (email, phone) for emergency
        console.log(`URGENT: Secret rotated for ${subscriber.id}`);
        // In production: send email, SMS, or PagerDuty alert
    }
}

Expected output: Emergency revocation immediately invalidates the compromised secret, generates a new one, forces all consumers to update, logs the security incident, and notifies the security team.

Common Mistakes

1. No Rotation Schedule

Without scheduled rotation, secrets remain valid for years. If compromised, the damage window is unlimited. Implement 90-day rotation. Automate it. Test rotation in staging first.

2. Instant Old Secret Deactivation

Deactivating the old secret immediately breaks all consumers that have not yet updated. Always use an overlap period (3-7 days). Both secrets are valid during the overlap.

3. Not Notifying Consumers

Rotating secrets without notifying consumers causes widespread delivery failures. Send clear notifications with the new secret, effective date, and overlap end date. Provide an API to fetch current secrets.

4. Storing Secrets in Code

Secrets in environment variables or config files are hard to rotate across all instances. Use a secrets manager (Vault, AWS Secrets Manager). Fetch secrets at runtime. Rotate centrally.

5. No Emergency Rotation Plan

When a secret is compromised, every minute matters. Have a documented emergency rotation procedure. Automate as much as possible. Include communication templates for consumer notification.

Practice Questions

1. Why use an overlap period during secret rotation?

The overlap period allows consumers to update at their own pace. Old webhooks signed with the old secret are still valid. New webhooks use the new secret. Consumers can switch without delivery gaps.

2. How do consumers know which secret to use for verification?

The signature header includes a key ID. Consumers look up the secret by key ID. Multiple key IDs in the header mean multiple valid secrets. Consumers try each until verification succeeds.

3. What triggers emergency secret rotation?

Suspected compromise: leaked secret in logs, exposed in git history, unauthorized webhook deliveries, security audit finding, employee departure with secret access, or Compliance requirement.

4. How do you verify consumers have updated to the new secret?

Monitor delivery success rates after rotation. Failed deliveries with signature errors indicate consumers using the old secret. Contact them directly. Set a deadline for update.

Challenge

Build a complete secret rotation system: 90-day automatic rotation with 7-day overlap, consumer notification via webhook and email, secret storage in HashiCorp Vault, emergency revocation with instant old secret deactivation, consumer verification after rotation, and a dashboard showing secret status per subscriber.

FAQ

How often should webhook secrets be rotated?

Every 90 days is standard. Financial or healthcare systems may rotate every 30 days. Security standards (PCI-DSS, SOC2) require at least annual rotation.

How long should the overlap period be?

3-7 days is typical. Long enough for all consumers to update. Short enough to limit exposure if the old secret is compromised. Adjust based on consumer update velocity.

Can I rotate secrets without notifying consumers?

If consumers fetch secrets from your API, they can discover the new secret automatically. Provide a GET /webhook-secrets endpoint. Consumers check it periodically.

What happens to webhooks signed with the old secret after rotation?

During the overlap period, old webhooks remain valid. After the overlap, webhooks signed with the old secret are rejected because the old secret is deactivated.

How do I handle rotation for inactive subscribers?

Inactive subscribers may never update their secret. Deactivate their webhook endpoint. Notify them via email. Reactivate only after they confirm secret update.

Mini Project: Secret Rotation Dashboard

Build a dashboard for secret management: current secret version and age, rotation schedule and countdown, overlap period status, subscriber adoption progress (% updated), emergency rotation button with confirmation, audit log of all rotations, and notification templates for consumer communication.

What's Next

Now that you understand secret rotation, learn about Testing Webhooks with ngrok and smee for local development and Integration Testing.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro