Rotating client certificates for mTLS webhooks without dropped deliveries

Your monitoring finally noticed: the client certificate the delivery fleet presents to a partner’s webhook endpoint expires in 30 days. Unlike an expiring signing secret, a lapsed client certificate fails at the TLS handshake — no request body is ever sent, no application log line appears on either side, and every delivery in flight turns into a connection error at once. This guide is the rotation procedure that avoids that cliff, extending Mutual TLS for Webhooks and picking up exactly where Configuring mTLS for webhook endpoints stopped, at the 90-day leaf it told you to track. The shape of the fix mirrors zero-downtime webhook secret rotation: never have exactly one valid credential during a change. The mechanics differ, because a certificate has two independent things to overlap — the leaf’s validity period and the issuing CA’s presence in the verifier’s trust bundle — and getting their order backwards is the classic way to turn a routine rotation into an outage.

The whole procedure rests on one invariant: a certificate must be trusted everywhere before it is presented anywhere, and it must stay trusted until nothing presents it. Trust is what the receiver configures; presentation is what the sender does. If presentation moves first, every handshake using the new leaf fails with unknown ca until the trust rollout catches up.

Overlapping windows during client certificate rotation Four parallel timelines showing the old leaf validity, the new leaf validity, the trust bundle contents, and which certificate senders present, with trust of the new CA starting before the cutover. trust new CA cut over retire old CA old leaf old leaf valid new leaf new leaf valid trust store CA A CA A + CA B CA B presented old cert new cert time
Trust for the new CA opens before the first sender presents the new leaf and closes only after the last one has stopped presenting the old one.

Prerequisites

Step 1: Issue the replacement with an overlapping validity window

Start by reading the facts off the certificate that is actually deployed, rather than the one in the repository — drift between them is common and it determines your real deadline.

# What is deployed right now: subject, issuer, and both validity bounds.
openssl x509 -in /etc/webhook/tls/client.pem -noout -subject -issuer -dates -serial

# Confirm the endpoint's expectation matches, straight off the wire.
openssl s_client -connect hooks.partner.example:443 \
  -cert /etc/webhook/tls/client.pem -key /etc/webhook/tls/client.key \
  -CAfile /etc/webhook/tls/server-ca.pem </dev/null 2>/dev/null \
  | openssl x509 -noout -dates

Generate a new key — rotation that reuses the old private key rotates nothing — and sign a leaf that starts today and outlives the old one. Keep the subject identical if the receiver maps subjects to tenants, because changing CN and rotating the key at the same time means two variables in one change.

# 1. New private key (never copy the old one).
openssl genrsa -out client-b.key 2048

# 2. CSR with the SAME subject the receiver already maps to a tenant.
openssl req -new -key client-b.key \
  -subj "/CN=payments-provider/O=Example/OU=tenant-42" \
  -out client-b.csr

# 3. Sign it. notBefore = now, notAfter = now + 90d, overlapping the old leaf by 30d.
openssl x509 -req -in client-b.csr -CA ca-b.crt -CAkey ca-b.key \
  -CAcreateserial -days 90 -sha256 \
  -extfile <(printf 'keyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=clientAuth\n') \
  -out client-b.crt

# 4. Ship the leaf plus its issuing chain, leaf FIRST, so the server can build a path.
cat client-b.crt ca-b.crt > client-b-fullchain.pem
openssl verify -CAfile ca-b.crt client-b.crt   # -> client-b.crt: OK

If you are rotating only the leaf and keeping the same CA, ca-b.crt is ca.crt and Step 2 is a no-op verification. If the CA itself is being replaced — because it is expiring, or moving into a KMS — the two overlaps must be nested: the CA overlap opens first and closes last.

Step 2: Trust the new CA everywhere before anything presents it

The trust bundle is just concatenated PEMs, so trusting two issuers costs one cat. Deploy it to every terminator — every region, every canary, every stack somebody forgot — and only then continue.

# Trust bundle: both issuers, old first for readability.
cat ca-a.crt ca-b.crt > /etc/nginx/certs/client-ca-bundle.pem

# Sanity-check what is actually in the bundle before reloading.
openssl crl2pkcs7 -nocrl -certfile /etc/nginx/certs/client-ca-bundle.pem \
  | openssl pkcs7 -print_certs -noout
server {
    listen 443 ssl;
    server_name hooks.partner.example;
    ssl_protocols TLSv1.3;

    # Both issuers are acceptable for the duration of the rotation.
    ssl_client_certificate /etc/nginx/certs/client-ca-bundle.pem;
    ssl_verify_client on;
    ssl_verify_depth  2;

    location /webhooks/payments {
        if ($ssl_client_verify != SUCCESS) { return 403; }
        proxy_set_header X-Client-Verify  $ssl_client_verify;
        proxy_set_header X-Client-Subject $ssl_client_s_dn;
        # Issuer DN lets you prove when the old CA stops being used.
        proxy_set_header X-Client-Issuer  $ssl_client_i_dn;
        proxy_pass http://127.0.0.1:8080;
    }
}

nginx -t && nginx -s reload applies the bundle without dropping established connections. Forwarding $ssl_client_i_dn is what makes Step 4 a measurement rather than a guess: the application can now count handshakes per issuer.

Rotation states and transitions States for the rotation: steady on the old CA, dual trust, cutover to the new certificate, soak while watching handshakes, and steady on the new CA, with a rollback transition back to dual trust. rollback: keep cert A steady CA A + cert A dual trust CA A + CA B cutover present cert B soak watch handshakes steady CA B + cert B trust CA B send cert B monitor no CA A traffic
Every transition is reversible until the old CA leaves the bundle, which is why retirement is the last step and never the same change as the cutover.
Phase Trust bundle Senders present Rollback move
Steady (before) CA A cert A none needed
Dual trust CA A + CA B cert A remove CA B, no traffic impact
Cutover CA A + CA B cert B (rolling) redeploy cert A, still trusted
Soak CA A + CA B cert B redeploy cert A, still trusted
Steady (after) CA B cert B re-add CA A, then redeploy cert A

Step 3: Distribute the new key pair and hot-reload senders

Push the new certificate and key into the secret store as a new version, let each sender read it from disk or from the store’s SDK, and swap the TLS identity in-process. Restarting the fleet works too, but a rolling restart drops keep-alive connections and any in-flight delivery on them; an in-process swap does not.

Certificate distribution and trust deployment path The certificate authority issues the new leaf into the secret store which delivers it to the sender fleet, while the same authority's certificate is deployed into the receiver trust bundle first, and an expiry monitor scrapes the terminator. Private CA issues leaf B Secret store versioned Sender fleet hot reload cert B deliver CA B cert presents cert B Trust bundle CA A + CA B TLS terminator verifies client deploy this first notAfter scrape Expiry monitor alerts at 21 days
The certificate and the trust anchor travel on different paths at different times; only the trust path is allowed to arrive late.

The sender keeps its TLS identity behind a single mutable agent reference. Building a new agent and closing the old one lets in-flight requests finish on the old sockets while every new request uses the new identity.

import { readFile } from 'node:fs/promises';
import { watch } from 'node:fs';
import { X509Certificate } from 'node:crypto';
import { Agent, request } from 'undici';

const CERT_PATH = process.env.WEBHOOK_CLIENT_CERT ?? '/etc/webhook/tls/client-fullchain.pem';
const KEY_PATH = process.env.WEBHOOK_CLIENT_KEY ?? '/etc/webhook/tls/client.key';
const SERVER_CA = process.env.WEBHOOK_SERVER_CA ?? '/etc/webhook/tls/server-ca.pem';

let identity: Agent | null = null;

async function buildAgent(): Promise<Agent> {
  const [cert, key, ca] = await Promise.all([readFile(CERT_PATH), readFile(KEY_PATH), readFile(SERVER_CA)]);
  // X509Certificate reads the FIRST certificate in the PEM: the leaf, if the chain is ordered correctly.
  const leaf = new X509Certificate(cert);
  const notAfter = new Date(leaf.validTo);
  const notBefore = new Date(leaf.validFrom);
  const now = new Date();
  if (now < notBefore || now >= notAfter) {
    throw new Error(`refusing to load client cert outside validity: ${leaf.validFrom} .. ${leaf.validTo}`);
  }
  return new Agent({
    connect: { cert, key, ca, minVersion: 'TLSv1.3' },
    keepAliveTimeout: 30_000,
    keepAliveMaxTimeout: 60_000,
  });
}

export async function reloadClientIdentity(): Promise<void> {
  const next = await buildAgent();
  const previous = identity;
  identity = next;               // new deliveries use the new leaf immediately
  await previous?.close();       // graceful: in-flight requests drain on old sockets
  const leaf = new X509Certificate(await readFile(CERT_PATH));
  console.info({ event: 'mtls_identity_reloaded', subject: leaf.subject, not_after: leaf.validTo });
}

export async function deliver(url: string, body: string): Promise<number> {
  if (!identity) await reloadClientIdentity();
  const res = await request(url, {
    method: 'POST',
    body,
    headers: { 'content-type': 'application/json' },
    dispatcher: identity!,
  });
  await res.body.dump();
  return res.statusCode;
}

// Trigger a reload when the mounted secret changes, and on SIGHUP for manual control.
watch(CERT_PATH, { persistent: false }, () => {
  reloadClientIdentity().catch((err) => console.error({ event: 'mtls_reload_failed', err: String(err) }));
});
process.on('SIGHUP', () => {
  reloadClientIdentity().catch((err) => console.error({ event: 'mtls_reload_failed', err: String(err) }));
});

Roll this out to one sender first and confirm the receiver logs a SUCCESS verify with the new issuer DN before the rest of the fleet follows. Because both leaves are valid and both CAs are trusted, a partially rolled-out fleet is a supported state, not a race.

Step 4: Retire the old certificate and the old CA

Retirement is a separate change, made only after evidence. Count handshakes by issuer on the receiving side and wait for the old issuer to go to zero and stay there for at least one full retry cycle — a sender that only fires on month-end batches will otherwise reappear after you have deleted its trust anchor.

import type { Request, Response, NextFunction } from 'express';
import { Counter } from 'prom-client';

export const handshakesByIssuer = new Counter({
  name: 'webhook_mtls_handshakes_total',
  help: 'Verified inbound mTLS handshakes, labelled by issuing CA',
  labelNames: ['issuer', 'subject'] as const,
});

export function recordIssuer(req: Request, res: Response, next: NextFunction) {
  if (req.header('x-client-verify') !== 'SUCCESS') return res.status(403).end();
  const issuer = req.header('x-client-issuer') ?? 'unknown';
  handshakesByIssuer.labels(issuer, req.header('x-client-subject') ?? 'unknown').inc();
  next();
}

When sum(increase(webhook_mtls_handshakes_total{issuer=~".*CA A.*"}[7d])) is 0, drop ca-a.crt from the bundle, reload, and destroy the old private key in the secret store (delete the version; do not merely overwrite the file on one host). Only now is the rotation finished.

Step 5: Automate renewal and alert on approaching expiry

Rotation you perform by hand once a quarter will eventually be skipped. Export days-until-expiry for every certificate the sender loads and for every CA in the bundle, so the alert fires with enough lead time to run the whole overlap procedure rather than a panicked swap.

import { readFile } from 'node:fs/promises';
import { X509Certificate } from 'node:crypto';
import { Gauge } from 'prom-client';

export const certExpiryDays = new Gauge({
  name: 'tls_certificate_expiry_days',
  help: 'Days until notAfter for a TLS certificate in use',
  labelNames: ['role', 'subject'] as const,
});

export function daysUntilExpiry(pem: string | Buffer, now = new Date()): number {
  const cert = new X509Certificate(pem);
  return (new Date(cert.validTo).getTime() - now.getTime()) / 86_400_000;
}

/** Split a bundle into individual PEM blocks so every CA is measured, not just the first. */
export function splitPemBundle(bundle: string): string[] {
  return bundle.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) ?? [];
}

export async function sampleExpiry(paths: Record<string, string>): Promise<void> {
  for (const [role, path] of Object.entries(paths)) {
    const pem = await readFile(path, 'utf8');
    for (const block of splitPemBundle(pem)) {
      const cert = new X509Certificate(block);
      certExpiryDays.labels(role, cert.subject).set(daysUntilExpiry(block));
    }
  }
}

setInterval(() => {
  sampleExpiry({
    client_leaf: process.env.WEBHOOK_CLIENT_CERT!,
    trust_bundle: process.env.WEBHOOK_SERVER_CA!,
  }).catch((err) => console.error({ event: 'expiry_sample_failed', err: String(err) }));
}, 3_600_000).unref();

Alert at 21 days as a warning and 7 days as a page: min(tls_certificate_expiry_days) by (role, subject) < 21. Twenty-one days is not arbitrary — it is one trust rollout, one canary, one soak, and one retirement with slack. If your issuance is automated through an ACME-style or cert-manager flow, keep the alert anyway: the failure you are guarding against is the automation silently stopping.

Verification and testing

Prove both certificates work simultaneously during the overlap, which is the property the whole procedure depends on:

# Old leaf must still be accepted during the overlap window.
openssl s_client -connect hooks.partner.example:443 -tls1_3 \
  -cert client-a.crt -key client-a.key -CAfile server-ca.pem </dev/null 2>&1 \
  | grep -E 'Verify return code|Verification'

# New leaf must be accepted too, before any sender is cut over.
openssl s_client -connect hooks.partner.example:443 -tls1_3 \
  -cert client-b-fullchain.pem -key client-b.key -CAfile server-ca.pem </dev/null 2>&1 \
  | grep -E 'Verify return code|Verification'

# End-to-end with the new identity: expect 204, not 400 "No required SSL certificate".
curl -sS -o /dev/null -w '%{http_code}\n' https://hooks.partner.example/webhooks/payments \
  --cert client-b-fullchain.pem --key client-b.key --cacert server-ca.pem \
  -H 'Content-Type: application/json' -d '{"event":"payment.succeeded"}'

# Key and certificate must belong together; these two hashes must match.
openssl x509 -noout -modulus -in client-b.crt | openssl md5
openssl rsa  -noout -modulus -in client-b.key | openssl md5

Guard the invariants in CI so a bad bundle never ships:

import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { X509Certificate } from 'node:crypto';
import { daysUntilExpiry, splitPemBundle } from './expiry.js';

const bundle = readFileSync('deploy/certs/client-ca-bundle.pem', 'utf8');
const leaf = readFileSync('deploy/certs/client-fullchain.pem', 'utf8');

describe('mTLS rotation invariants', () => {
  it('trusts both issuers during the overlap', () => {
    expect(splitPemBundle(bundle)).toHaveLength(2);
  });

  it('keeps at least 21 days of leaf validity', () => {
    expect(daysUntilExpiry(leaf)).toBeGreaterThan(21);
  });

  it('ships the leaf first in the chain file', () => {
    const first = new X509Certificate(splitPemBundle(leaf)[0]);
    expect(first.subject).toContain('CN=payments-provider');
    expect(first.ca).toBe(false);
  });

  it('has an issuer that is present in the trust bundle', () => {
    const first = new X509Certificate(splitPemBundle(leaf)[0]);
    const issuers = splitPemBundle(bundle).map((pem) => new X509Certificate(pem).subject);
    expect(issuers).toContain(first.issuer);
  });
});

Failure modes and gotchas

Frequently Asked Questions

Do we have to rotate the issuing CA whenever we rotate the leaf?

No, and most rotations should not. A leaf signed by the same CA needs no trust-bundle change at all, which reduces the procedure to a key swap and removes the riskiest ordering constraint from it. Rotate the CA only when it is itself approaching expiry or moving to a new signer, and when both must move together, treat them as two rotations run back to back rather than one change.

What does the receiver actually log when a sender presents an expired leaf?

A TLS alert, not an HTTP request, so the access log shows nothing at all and the error log shows a terse handshake failure with a peer address and no subject. nginx reports these at the info level, which most deployments never capture, so the first visible symptom is usually a drop in request volume measured on the sender's side. That asymmetry is why the expiry gauge matters more than any log-based alert: by the time the logs help, deliveries are already failing.

Can we run 30-day leaves instead of 90 to shrink exposure?

Shorter leaves are better, but only once issuance and reload are fully automated, because a 30-day leaf means twelve overlaps a year and a manual procedure will eventually skip one. The arithmetic also has to work: a 21-day soak against a 30-day lifetime leaves almost no slack, so shorten the soak or keep the longer leaf rather than compressing both. Automate the issuance path first and shorten the lifetime second.

Does revoking the old certificate replace the retirement soak?

No. Revocation for client certificates is checked inconsistently, and many terminators validate the chain while ignoring revocation entirely unless a CRL is explicitly configured, so revoking does not reliably stop the old leaf from being accepted. Publish the revocation as defence in depth, but keep the handshake-count evidence as the control you actually gate retirement on.

Our certificate is a mounted Kubernetes Secret, so will the file watch fire when it rotates?

Often not. The kubelet updates a projected secret by swapping a symlink to a fresh timestamped directory rather than rewriting the file in place, and a watch registered directly on the leaf path can stay bound to the old inode and never fire again. Watch the parent directory instead, or poll the certificate fingerprint once a minute and reload on change, keeping the signal handler as the manual escape hatch.

What if the receiver pins our certificate instead of trusting our CA?

Pinning defeats the overlap, because a pin on the old leaf or its public key rejects the new one regardless of how the trust bundle is arranged. The receiver has to accept a pin set holding both fingerprints for the duration, which makes their deployment a blocking dependency on your schedule. Agree that change in writing before you issue the replacement, and push for a pin on the issuing CA so routine leaf rotation stops being a coordinated release.