Webhook Secret Rotation — Complete Guide
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
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