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:
- ›The server presents its certificate (standard TLS)
- ›The client presents its certificate
- ›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 property | HMAC signature | mTLS | Both |
|---|---|---|---|
| Payload integrity | Yes | No | Yes |
| Payload authenticity | Yes (with secret) | No | Yes |
| Transport authentication (server) | No | Yes | Yes |
| Transport authentication (client) | No | Yes | Yes |
| Replay attack prevention | Yes (with timestamp) | No | Yes |
| Protection against secret compromise | No | Yes (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:
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:
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:
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:
- ›Generate a new client certificate (issued from the same CA)
- ›Update the destination config to reference the new certificate
- ›Allow a grace period where either the old or new certificate is accepted by the destination
- ›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:
# During CA rotation: trust both old and new CA
ssl_client_certificate /etc/ssl/combined-ca.crt; # concatenated PEM fileGetHook 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.
| Scenario | mTLS justified? |
|---|---|
| SaaS sending webhooks to startup customers | No — HMAC is sufficient |
| Fintech platform sending payment events to enterprise bank APIs | Yes |
| Healthcare data exchange under HIPAA BAA | Often required contractually |
| Internal microservice communication on a private network | Usually no — network controls sufficient |
| Government contract with NIST 800-53 controls | Yes — likely mandated |
| Marketplace sending order events to third-party fulfillment | Situational — 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:
| Failure | Symptom | Resolution |
|---|---|---|
| Expired client certificate | TLS handshake fails with certificate has expired | Rotate the client cert; check 30-day pre-expiry alerts |
| Destination doesn't require mTLS | Connection succeeds without presenting cert | This is not a failure — but log that mTLS was configured but not enforced |
| CA not trusted by destination | TLS handshake fails with unknown CA | Customer needs to install the CA cert on their server |
| CN mismatch on destination side | TLS handshake succeeds, HTTP returns 403 | Verify the destination's CN check matches your certificate's CN |
| Certificate revoked | TLS handshake fails with certificate revoked | Issue 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 →