TLS configuration for webhook endpoints

Webhook TLS problems are unusually expensive because they are silent on the way in and loud on the way out. A sender with certificate verification switched off delivers successfully to an attacker for months without a single error log; a consumer whose certificate quietly expired on a Saturday discovers it on Monday, along with a queue of failed deliveries and a support thread. This page covers the transport layer on both ends of a webhook exchange, as the third leg of webhook endpoint hardening alongside preventing SSRF in outbound webhook delivery, which governs where you may connect while this one governs how that connection is established and trusted.

Keep three verifications separate in your head, because a single configuration flag usually disables all three at once and engineers rarely intend that. Chain verification asks whether a CA you trust signed this certificate. Hostname verification asks whether this certificate was issued for the host you asked for. Validity verification asks whether the certificate is inside its notBefore/notAfter window. Any one of them failing must fail the delivery, and none of them is optional in production.

Prerequisites

Step 1: Set the protocol floor and cipher policy

Runtime defaults drift. A Node minor release can change the default cipher list, and a base image update can change the OpenSSL policy underneath it — so put the policy in your repository where a code review can see it. For webhook delivery the floor is TLS 1.2, the preference is TLS 1.3, and everything below 1.2 is refused rather than negotiated.

TLS 1.3 is worth preferring for two operational reasons beyond cryptographic hygiene: it completes in one round trip instead of two, which matters when you are opening thousands of short-lived connections to distinct consumers, and its cipher suite list is small and entirely forward-secret, which removes a whole category of configuration mistake.

import { Agent } from 'undici';

// TLS 1.3 suites are fixed by the protocol; this list only affects a 1.2
// downgrade. Every entry is ECDHE, so forward secrecy holds on both paths.
const TLS12_CIPHERS = [
  'ECDHE-ECDSA-AES128-GCM-SHA256',
  'ECDHE-RSA-AES128-GCM-SHA256',
  'ECDHE-ECDSA-AES256-GCM-SHA384',
  'ECDHE-RSA-AES256-GCM-SHA384',
  'ECDHE-ECDSA-CHACHA20-POLY1305',
  'ECDHE-RSA-CHACHA20-POLY1305',
].join(':');

export function createDeliveryAgent(): Agent {
  return new Agent({
    connect: {
      minVersion: 'TLSv1.2',       // refuse 1.0 and 1.1 outright
      maxVersion: 'TLSv1.3',       // negotiate 1.3 whenever the peer supports it
      ciphers: TLS12_CIPHERS,
      honorCipherOrder: true,
      // rejectUnauthorized defaults to true — never set it to false here.
    },
    connectTimeout: 3_000,
    headersTimeout: 5_000,
    bodyTimeout: 10_000,
    keepAliveTimeout: 10_000,
    keepAliveMaxTimeout: 60_000,
  });
}
Protocol version policy matrix TLS versions 1.0 through 1.3 compared on forward secrecy, handshake round trips and the delivery policy applied to each. Version Forward secrecy Handshake cost Delivery policy TLS 1.0 cipher dependent 2 round trips refuse the handshake TLS 1.1 cipher dependent 2 round trips refuse the handshake TLS 1.2 with ECDHE suites only 2 round trips accepted floor TLS 1.3 always, by design 1 round trip preferred Declare the floor in code; a base image update must never lower it.
The round-trip column is the argument that wins budget: preferring 1.3 removes a full network round trip from every one of your short-lived delivery connections.

Engineering note: publish your floor in consumer documentation. A partner still terminating TLS 1.0 on a legacy appliance needs to know at integration time, not when their first delivery fails with an unhelpful EPROTO.

Step 2: Keep chain verification on, always

rejectUnauthorized: false is the most damaging two words in a webhook codebase. It usually enters as a temporary workaround for one consumer with a broken certificate, gets committed because the delivery started working, and then applies to every endpoint the agent serves. From that moment anyone able to intercept the connection can present any certificate and receive your signed payloads — and because the delivery succeeds, no metric, no log line and no alert fires. It is a vulnerability whose only symptom is that everything looks fine.

The same objection applies to NODE_TLS_REJECT_UNAUTHORIZED=0 in an environment file, which is worse still because it is invisible in code review. Ban both, and make the ban enforceable: fail CI on a grep, and assert at boot that the process is not running with verification disabled.

import { Agent, request } from 'undici';

export class TlsVerificationError extends Error {
  constructor(readonly code: string, readonly endpoint: string) {
    super(`tls_verification_failed:${code}`);
  }
}

// Boot-time assertion: a misconfigured environment must not start.
export function assertTlsHardened(): void {
  if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
    throw new Error('refusing to start: NODE_TLS_REJECT_UNAUTHORIZED is disabled');
  }
}

const VERIFICATION_CODES = new Set([
  'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
  'SELF_SIGNED_CERT_IN_CHAIN',
  'DEPTH_ZERO_SELF_SIGNED_CERT',
  'CERT_HAS_EXPIRED',
  'ERR_TLS_CERT_ALTNAME_INVALID',
  'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
]);

export async function deliver(url: URL, body: string, dispatcher: Agent) {
  try {
    return await request(url, { method: 'POST', body, dispatcher, maxRedirections: 0 });
  } catch (err) {
    const code = (err as NodeJS.ErrnoException).code ?? 'UNKNOWN';
    if (VERIFICATION_CODES.has(code)) {
      // A distinct reason code turns "delivery failed" into an actionable
      // message the consumer can fix themselves.
      throw new TlsVerificationError(code, url.host);
    }
    throw err;
  }
}

Engineering note: map each verification code to consumer-facing copy. CERT_HAS_EXPIRED and ERR_TLS_CERT_ALTNAME_INVALID have completely different fixes, and a delivery log that says only “connection error” guarantees a support ticket.

Step 3: Verify the hostname against the SAN

Chain verification alone proves someone with a trusted CA signature holds this certificate — not that it belongs to the host you meant to reach. Hostname verification closes that, matching the requested host against the certificate’s subjectAltName entries. The legacy commonName field is no longer consulted by modern clients, so a consumer whose certificate carries only a CN will fail here even though it “looks right” in a browser they tested with years ago.

Two things must agree: the SNI value you send during the handshake, and the name you check the certificate against. If you followed the address-pinning approach from the SSRF page, this is exactly where it matters — the socket connects to a validated IP while SNI and identity checking both continue to use the hostname.

import tls from 'node:tls';
import { Agent } from 'undici';

export function agentForHost(hostname: string): Agent {
  return new Agent({
    connect: {
      servername: hostname,     // SNI: virtual hosts return the right certificate
      minVersion: 'TLSv1.2',
      checkServerIdentity: (host, cert) => {
        // Wrap the default check; never replace it. Returning undefined from a
        // hand-written implementation silently accepts every certificate.
        const err = tls.checkServerIdentity(hostname, cert);
        if (err) return err;
        if (!cert.subjectaltname) {
          return new Error(`certificate for ${host} has no subjectAltName`);
        }
        return undefined;
      },
    },
  });
}
Certificate field anatomy A server certificate broken into issuer, subject, subject alternative names, validity dates and signature algorithm, each annotated with the check that consumes it. Three independent checks read three different parts of one certificate. Server certificate Issuer: Example CA R3 Subject: hooks.acme.io SAN: hooks.acme.io Not before: 2026-06-01 Not after: 2026-08-30 Key: ECDSA P-256 chain check: is the issuer trusted here? hostname check reads the SAN, never the CN validity check: expiry is a hard failure cipher list must match the key type subject is cosmetic to modern clients
An ECDSA certificate paired with an RSA-only cipher list fails the handshake before any of the three checks even runs — read the key type when a "valid" certificate is refused.

Engineering note: a consumer using an IP address as their webhook host needs the address in the SAN as an IP: entry, which most public CAs will not issue. In practice this means hostname-only endpoints, which is a rule worth enforcing at registration alongside the checks in preventing SSRF in outbound webhook delivery.

Step 4: Trust a private CA per endpoint, never globally

Some consumers genuinely cannot present a publicly trusted certificate: an endpoint inside a corporate network, a regulated environment with an internal PKI, a partner whose appliance only supports their own root. The wrong answer is to disable verification. The right answer is to trust their CA, for their endpoint, and for nothing else.

Scope it three ways. Scope it by endpoint, so a dispatcher built for one consumer carries their bundle and no other. Scope it in time, with an expiry date on the exception that forces a review rather than letting it become permanent. Scope it in visibility, by listing every active exception on a dashboard so the count is a number someone owns and drives down.

import { Agent } from 'undici';

interface EndpointRecord {
  id: string;
  url: string;
  privateCaPem: string | null;
  caExceptionExpiresAt: string | null; // ISO date; null when no exception exists
}

export function dispatcherFor(endpoint: EndpointRecord, now = new Date()): Agent {
  const base = {
    minVersion: 'TLSv1.2' as const,
    servername: new URL(endpoint.url).hostname,
  };
  if (!endpoint.privateCaPem) return new Agent({ connect: base });

  const expiresAt = endpoint.caExceptionExpiresAt
    ? new Date(endpoint.caExceptionExpiresAt)
    : null;
  if (!expiresAt || expiresAt < now) {
    // An expired exception is not a grace period. Fail the delivery and let the
    // renewal conversation happen, rather than extending it silently forever.
    throw new Error(`ca_exception_expired:${endpoint.id}`);
  }
  return new Agent({
    connect: {
      ...base,
      // ADD this CA for this endpoint. Verification stays fully enabled: the
      // chain must still validate, and the hostname must still match the SAN.
      ca: [endpoint.privateCaPem],
    },
  });
}
Untrusted certificate decision tree A failed handshake branches on whether the consumer can obtain a public certificate, operates a stable private CA, or only has an ad hoc self-signed certificate. Every branch ends in trust that is scoped; none ends in verification off. Handshake fails: untrusted certificate A public CA can issue for this hostname They run an internal CA with a stable root Self-signed, replaced whenever it expires Use it. Nothing changes on the sender side. Pin that root for this endpoint only, as an exception that expires. Refuse. A rotating leaf cannot be trusted.
The middle branch is the only one that touches your configuration, and it is deliberately the most bureaucratic so it stays rare.

Engineering note: pin the root, not the leaf. Pinning a leaf certificate means the delivery breaks every time the consumer renews, typically every ninety days, and the pressure that creates is what eventually pushes someone to disable verification instead.

Step 5: Monitor certificate expiry and handshake failures

Certificate expiry is the most predictable outage in this entire area and still the most common. Ninety-day certificates plus a renewal automation that silently stopped equals a webhook integration that dies on a specific afternoon. Because you know the expiry date in advance, you can turn it into a gauge and alert on it long before it becomes a delivery failure — and you are often better placed to notice than the consumer is.

Probe each registered endpoint on a schedule, read notAfter from the peer certificate, and export days-remaining per endpoint. Escalate in stages: a ticket at 30 days, an email to the endpoint owner at 14, a page to your own on-call at 3, because at that point you are about to start failing deliveries and queueing retries. Feed the same signal into the failure alerting described in alerting on webhook delivery failures so a certificate expiry and a generic outage do not look identical on the dashboard.

import tls from 'node:tls';

export interface CertHealth {
  host: string;
  daysRemaining: number;
  issuer: string;
  protocol: string | null;
}

export function probeCertificate(host: string, port = 443): Promise<CertHealth> {
  return new Promise((resolve, reject) => {
    const socket = tls.connect(
      { host, port, servername: host, minVersion: 'TLSv1.2', timeout: 5_000 },
      () => {
        const cert = socket.getPeerCertificate();
        if (!cert || !cert.valid_to) {
          socket.destroy();
          return reject(new Error(`no_peer_certificate:${host}`));
        }
        const expiresAt = new Date(cert.valid_to).getTime();
        resolve({
          host,
          daysRemaining: Math.floor((expiresAt - Date.now()) / 86_400_000),
          issuer: cert.issuer?.O ?? 'unknown',
          protocol: socket.getProtocol(),
        });
        socket.end();
      },
    );
    socket.on('timeout', () => socket.destroy(new Error(`tls_timeout:${host}`)));
    socket.on('error', reject);
  });
}

export function severityFor(days: number): 'ok' | 'ticket' | 'email' | 'page' {
  if (days > 30) return 'ok';
  if (days > 14) return 'ticket';
  if (days > 3) return 'email';
  return 'page';
}
Certificate lifecycle states A certificate moves from valid to a renewal window at thirty days, to expiring at seven days, to expired, with a renewal transition back to valid and escalating alert levels beneath each state. The only transition you control is the loop back to valid. Valid over 30 days left Renewal window 30 days left Expiring 3 days left Expired all deliveries fail T-30 T-3 T-0 renewal observed on the next probe ticket the owner page on-call open incident
Escalation is tied to states rather than to a single threshold, so a certificate renewed at day 20 stops alerting without anyone silencing anything.

Engineering note: probe the endpoint’s real hostname and port, not a health-check URL on a different host. Large consumers frequently terminate webhook traffic on a different load balancer from their main site, with a different certificate and a different renewal owner.

Verification and testing

Start from the wire. openssl s_client tells you the negotiated protocol, the cipher, the chain, and the verification result in one command — and it answers questions your application logs cannot.

# Negotiated protocol, cipher and verification result in one shot.
openssl s_client -connect hooks.acme.io:443 -servername hooks.acme.io -brief </dev/null

# Prove the floor holds: this MUST fail against a correctly configured peer.
openssl s_client -connect hooks.acme.io:443 -tls1_1 </dev/null

# Read the validity window without wading through the full certificate.
openssl s_client -connect hooks.acme.io:443 -servername hooks.acme.io </dev/null \
  | openssl x509 -noout -dates -ext subjectAltName

Then pin the behaviour in tests, using a locally generated self-signed certificate so the rejection path is exercised on every run rather than assumed.

import { describe, expect, it } from 'vitest';
import { severityFor, probeCertificate } from './tls';
import { deliver, createDeliveryAgent } from './delivery';

describe('tls policy', () => {
  it('rejects a self-signed certificate rather than delivering to it', async () => {
    const agent = createDeliveryAgent();
    // Local server started with a self-signed cert on 127.0.0.1:8443.
    await expect(
      deliver(new URL('https://localhost:8443/webhooks'), '{}', agent),
    ).rejects.toThrow(/tls_verification_failed/);
  });

  it('escalates by remaining lifetime', () => {
    expect(severityFor(45)).toBe('ok');
    expect(severityFor(20)).toBe('ticket');
    expect(severityFor(7)).toBe('email');
    expect(severityFor(1)).toBe('page');
  });

  it('reports the negotiated protocol for a healthy endpoint', async () => {
    const health = await probeCertificate('hooks.acme.io');
    expect(health.protocol).toMatch(/TLSv1\.[23]/);
    expect(health.daysRemaining).toBeGreaterThan(0);
  });
});

A useful CI addition is a grep gate: fail the build if rejectUnauthorized: false, NODE_TLS_REJECT_UNAUTHORIZED, or -k in a delivery script appears anywhere outside a test fixture directory. It is a crude check that catches the exact regression that matters most.

Failure modes and gotchas

Frequently Asked Questions

A consumer offers a self-signed certificate rather than a private CA — is that the same exception?

No, and it is meaningfully worse to operate. With a private CA the trust anchor is the root, so the consumer can reissue their leaf whenever they like and nothing on your side changes; with a self-signed certificate the leaf is the anchor, so every renewal is a coordinated change on both ends. Ask them to stand up even a minimal internal CA and trust that root instead, and if you must accept the leaf, record its expiry date as a dated task rather than discovering it as a delivery failure.

Should the dispatcher perform revocation checking with OCSP or CRLs?

In practice it buys very little on the sending side. Clients typically soft-fail when a responder is unreachable, which means an attacker who can intercept the connection can also suppress the check, and you have added a per-delivery dependency on a third party's availability. Prefer short-lived certificates and expiry monitoring, ask consumers to enable stapling so the evidence arrives inside the handshake, and keep a manual path to distrust a specific endpoint immediately if a key is known to be compromised.

Does connection pooling interfere with per-endpoint trust settings?

It can, in two ways worth checking. A pool keyed only by origin will happily hand a socket established under one endpoint's trust configuration to a delivery for a different endpoint that shares the host, so give each dispatcher its own pool rather than sharing a global one. Long-lived keep-alive sockets also outlive a policy change, so an expired CA exception keeps working until the connection is recycled; bound the maximum socket lifetime so configuration reliably takes effect.

Can we simply require TLS 1.3 and drop 1.2 entirely?

Measure before you decide. Export the negotiated protocol version per endpoint as a label for a few weeks and you will usually find a small tail of appliances and inspection middleboxes that still cannot complete a 1.3 handshake, concentrated in exactly the regulated customers hardest to move. Publish a deprecation date, contact the tail directly with the evidence from your own telemetry, and raise the floor once that group is empty rather than discovering it during the change.

A certificate that verifies on a laptop fails from a worker container — where do we look?

Compare trust stores before anything else. A slim base image ships a different, often older set of root certificates than a developer machine, so a CA added to the public program recently may be absent inside the container, and a base image rebuild can silently change the set under you. Pin the certificate bundle as an explicit dependency with a version you control, and log the resolved bundle path and its fingerprint at boot so the difference is visible in one log line.

Is a failed handshake worth retrying on the normal delivery schedule?

Split it by cause. An expired certificate or a name mismatch is a state that will not resolve inside your retry horizon, so retrying just burns budget and delays the notification the consumer actually needs; mark the delivery failed with its reason code and alert the endpoint owner. A handshake timeout or a connection reset carries no such information and belongs on the usual backoff, so keep the two paths distinct in the delivery record.