Back to Blog
securitymTLSTLSauthenticationinfrastructure

mTLS Authentication for High-Security Webhook Destinations

Standard HMAC signatures verify payload integrity, but they don't authenticate the transport layer. Mutual TLS closes that gap — here's how to implement mTLS for webhook delivery when the stakes are high.

D
Dmitri Volkov
Distributed Systems Engineer
March 22, 2026
11 min read

HMAC signatures do one thing well: they let a receiver confirm that a payload was signed by someone who holds the secret. What they don't do is authenticate the transport connection itself. Any HTTP client on the internet can establish a TLS connection to your destination, present a valid-looking webhook payload, and be indistinguishable from the legitimate sender at the network layer.

For most webhook use cases, HMAC is sufficient. But in regulated industries — financial services, healthcare, government contractors — the security posture requires mutual authentication at the transport layer. That's where mutual TLS (mTLS) comes in.

This post covers what mTLS actually provides, where it falls short, how to implement it in your webhook delivery system, and when the complexity is justified.


What mTLS Actually Provides

Standard TLS is one-directional: the client verifies the server's certificate, but the server accepts any client. This is fine for most HTTPS traffic — you want any browser to be able to reach your API.

mTLS makes authentication bidirectional:

  1. The server presents its certificate (standard TLS)
  2. The client presents its certificate
  3. Both sides verify the other's certificate against a trusted CA

For webhook delivery, the "client" is your delivery worker and the "server" is the destination endpoint. With mTLS:

  • The destination rejects connections from any sender that doesn't present a trusted client certificate
  • Your delivery worker confirms it's talking to the correct destination (not a DNS-hijacked impostor)
  • No valid webhook can be delivered from an unauthorized network location, even if someone obtains a valid HMAC signing secret
Security propertyHMAC signaturemTLSBoth
Payload integrityYesNoYes
Payload authenticityYes (with secret)NoYes
Transport authentication (server)NoYesYes
Transport authentication (client)NoYesYes
Replay attack preventionYes (with timestamp)NoYes
Protection against secret compromiseNoYes (cert required)Partial

The combination of mTLS and HMAC signatures is defense in depth. Neither alone is complete.


Certificate Architecture

Before writing any code, decide on your certificate authority (CA) architecture. You have two options.

Option 1: Self-managed CA per account

Generate a CA keypair for each customer account. Issue client certificates from that CA for delivery. The destination configures their TLS stack to trust that account-specific CA.

Root CA (GetHook internal)
  └── Account CA (acme-corp)
        └── Delivery Client Cert (expires 90 days)

Pros: clean tenant isolation; revoking an account CA invalidates all its delivery certificates at once. Cons: customers need to install your account CA in their TLS stack, which is unusual for most web servers.

Option 2: Shared CA with per-destination certificates

One CA for all delivery, with unique leaf certificates per destination. The destination pins to a specific certificate (or the shared CA).

GetHook Delivery CA
  ├── dest_abc123.delivery.gethook.to (cert for destination A)
  └── dest_xyz789.delivery.gethook.to (cert for destination B)

Pros: simpler for customers — they trust one well-known CA. Cons: a compromised CA certificate affects all destinations.

For a webhook gateway with strict tenant isolation requirements, Option 1 is the better architectural choice. Option 2 is faster to ship and operationally simpler.


Generating Certificates in Go

Here's a minimal implementation for generating a CA and issuing a leaf client certificate programmatically:

go
package tlscert

import (
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/pem"
	"math/big"
	"time"
)

type CertBundle struct {
	CACert     []byte // PEM-encoded CA certificate (give to destination)
	ClientCert []byte // PEM-encoded client certificate
	ClientKey  []byte // PEM-encoded private key (never share)
}

func GenerateClientBundle(accountID string) (*CertBundle, error) {
	// Generate CA key
	caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, err
	}

	caTemplate := &x509.Certificate{
		SerialNumber:          big.NewInt(1),
		Subject:               pkix.Name{CommonName: "GetHook Delivery CA — " + accountID},
		NotBefore:             time.Now(),
		NotAfter:              time.Now().Add(10 * 365 * 24 * time.Hour),
		IsCA:                  true,
		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
		BasicConstraintsValid: true,
	}

	caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
	if err != nil {
		return nil, err
	}
	caCert, _ := x509.ParseCertificate(caCertDER)

	// Generate leaf client cert
	clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, err
	}

	clientTemplate := &x509.Certificate{
		SerialNumber: big.NewInt(2),
		Subject:      pkix.Name{CommonName: "gethook-delivery." + accountID},
		NotBefore:    time.Now(),
		NotAfter:     time.Now().Add(90 * 24 * time.Hour),
		KeyUsage:     x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
	}

	clientCertDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caCert, &clientKey.PublicKey, caKey)
	if err != nil {
		return nil, err
	}

	clientKeyDER, err := x509.MarshalECPrivateKey(clientKey)
	if err != nil {
		return nil, err
	}

	return &CertBundle{
		CACert:     pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCertDER}),
		ClientCert: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: clientCertDER}),
		ClientKey:  pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: clientKeyDER}),
	}, nil
}

The CA certificate (.CACert) is what you give your customer to install. The client certificate and private key stay in your delivery system — encrypted at rest, loaded into the HTTP client at delivery time.


Configuring the HTTP Client for mTLS Delivery

When your delivery worker makes an HTTP request to an mTLS-protected destination, it needs to present the client certificate:

go
func newMTLSClient(certPEM, keyPEM, caPEM []byte) (*http.Client, error) {
	cert, err := tls.X509KeyPair(certPEM, keyPEM)
	if err != nil {
		return nil, err
	}

	caCertPool := x509.NewCertPool()
	caCertPool.AppendCertsFromPEM(caPEM)

	tlsCfg := &tls.Config{
		Certificates: []tls.Certificate{cert},
		RootCAs:      caCertPool,
		MinVersion:   tls.VersionTLS13,
	}

	return &http.Client{
		Transport: &http.Transport{TLSClientConfig: tlsCfg},
		Timeout:   30 * time.Second,
	}, nil
}

In practice, you cache the *http.Client per destination to avoid repeated TLS handshake overhead. Keying the cache by destination_id works well — the cert is per-destination anyway.


Destination-Side Configuration

Your customers need to configure their web server to require client certificates. Here's what that looks like for nginx:

nginx
server {
    listen 443 ssl;
    server_name webhook.acme-corp.com;

    ssl_certificate     /etc/ssl/server.crt;
    ssl_certificate_key /etc/ssl/server.key;

    # Require client certificate
    ssl_client_certificate /etc/ssl/gethook-delivery-ca.crt;
    ssl_verify_client      on;
    ssl_verify_depth       2;

    location /webhooks {
        # Optionally, check the CN of the client certificate
        if ($ssl_client_s_dn !~ "gethook-delivery") {
            return 403;
        }
        proxy_pass http://127.0.0.1:3000;
    }
}

For customers running on managed cloud infrastructure (Lambda, Cloud Run, App Engine), client certificate verification requires sitting behind a load balancer that supports mutual TLS — AWS ALB with mTLS mode, GCP Certificate Manager, or Cloudflare mTLS rules.


Certificate Rotation

Leaf certificates should be short-lived — 90 days is a reasonable default. Rotation needs to be zero-downtime.

The safe rotation sequence:

  1. Generate a new client certificate (issued from the same CA)
  2. Update the destination config to reference the new certificate
  3. Allow a grace period where either the old or new certificate is accepted by the destination
  4. After confirming delivery with the new cert, revoke the old one

The tricky part is step 3 — "allow either certificate." This requires the destination to trust both. Since both are issued from the same CA, a destination that trusts the CA automatically accepts both. No destination-side change is needed for leaf rotation, only for CA rotation.

CA rotation is harder and should be infrequent (annual at most). When rotating the CA, you need the destination to trust both CAs during the transition window:

nginx
# During CA rotation: trust both old and new CA
ssl_client_certificate /etc/ssl/combined-ca.crt;  # concatenated PEM file

GetHook stores client certificates encrypted per destination, versions them, and handles rotation automatically for accounts that enable managed mTLS.


When Is mTLS Actually Worth It?

mTLS adds operational overhead — certificate management, customer onboarding steps, additional failure modes (expired certs causing delivery failures). Before requiring it, evaluate whether the security benefit justifies the cost.

ScenariomTLS justified?
SaaS sending webhooks to startup customersNo — HMAC is sufficient
Fintech platform sending payment events to enterprise bank APIsYes
Healthcare data exchange under HIPAA BAAOften required contractually
Internal microservice communication on a private networkUsually no — network controls sufficient
Government contract with NIST 800-53 controlsYes — likely mandated
Marketplace sending order events to third-party fulfillmentSituational — depends on data sensitivity

The clearest signal that mTLS is needed: your customer's security team has sent you a questionnaire asking about "client certificate authentication" or "mutual TLS." That's a sign it's a contractual requirement, not an optional hardening.


Handling mTLS Failure Modes

mTLS introduces new delivery failure modes beyond the standard HTTP failures you already handle:

FailureSymptomResolution
Expired client certificateTLS handshake fails with certificate has expiredRotate the client cert; check 30-day pre-expiry alerts
Destination doesn't require mTLSConnection succeeds without presenting certThis is not a failure — but log that mTLS was configured but not enforced
CA not trusted by destinationTLS handshake fails with unknown CACustomer needs to install the CA cert on their server
CN mismatch on destination sideTLS handshake succeeds, HTTP returns 403Verify the destination's CN check matches your certificate's CN
Certificate revokedTLS handshake fails with certificate revokedIssue and deploy a new certificate

Pre-expiry alerting is the most important operational concern. A delivery system that silently fails 90 days after setup because nobody renewed a certificate is a serious reliability problem. Alert at 30 days and 7 days before expiry.


Storing Private Keys

Client certificate private keys are among the most sensitive secrets in a webhook delivery system. A compromised private key allows an attacker to impersonate your delivery worker at the network level, bypassing mTLS entirely.

Requirements:

  • Encrypt private keys at rest using AES-256-GCM (the same pattern used for HMAC signing secrets)
  • Never log private keys or certificate contents
  • Private keys should be loaded into memory at delivery time, not kept in environment variables
  • Consider using a secrets manager (AWS Secrets Manager, HashiCorp Vault) for the encryption key that protects the private keys

In GetHook, all destination secrets — including mTLS private keys — are encrypted at rest. The plaintext key never persists to disk.


For teams building webhook infrastructure in regulated industries, mTLS is often the difference between passing a security audit and failing it. Set up mTLS destinations in GetHook →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.