Configuring mTLS for Webhook Endpoints
This guide walks through enabling mutual TLS on a single webhook endpoint terminated by nginx and enforced again at the application, the concrete build behind Mutual TLS for Webhooks. The scenario: a payment provider dispatches webhooks to your /webhooks/payments endpoint, and you must reject any caller that cannot present a client certificate signed by your private CA — before the request reaches application code. Because mTLS authenticates the connection while payload signing authenticates the body, pair this with the message-level checks in Step-by-step HMAC webhook validation in Node.js for defense-in-depth.
Prerequisites
- A private certificate authority (root or intermediate) whose public certificate you control. This guide creates one in Step 1.
- nginx 1.25+ (or any TLS terminator that supports client-certificate verification) fronting your application.
- OpenSSL 3.x and
curlbuilt against OpenSSL for the verification steps. - The webhook producer’s cooperation to install the issued client certificate, or your own out-of-band channel to deliver it.
- TLS 1.3 enabled end-to-end; disable TLS 1.0/1.1 entirely.
Step 1: Establish a private CA
Generate a CA key and self-signed CA certificate. Keep ca.key offline or in a hardware-backed store; it signs every client certificate you trust.
# CA private key (keep this secret and offline)
openssl genrsa -out ca.key 4096
# Self-signed CA certificate, valid 5 years
openssl req -x509 -new -nodes -key ca.key -sha256 -days 1825 \
-subj "/CN=Webhook Internal CA/O=Example" \
-out ca.crt
ca.crt becomes the trust anchor that nginx uses to validate incoming client certificates. It must contain only CAs you intend to trust — never append public web-PKI roots.
Step 2: Issue the client certificate
Create a key and certificate signing request (CSR) for the producer, then sign it with the CA. Encode the tenant identity in the subject so the application can map a verified connection to a tenant.
# Producer's key + CSR
openssl genrsa -out producer.key 2048
openssl req -new -key producer.key \
-subj "/CN=payments-provider/O=Example/OU=tenant-42" \
-out producer.csr
# Sign with the CA (90-day leaf; rotate before expiry)
openssl x509 -req -in producer.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -days 90 -sha256 -out producer.crt
# Confirm the chain validates
openssl verify -CAfile ca.crt producer.crt # -> producer.crt: OK
Deliver producer.crt and producer.key to the webhook producer over a secure channel. Track the 90-day expiry now so rotation overlaps rather than failing closed.
Step 3: Require client certificates at nginx
Configure the server block to terminate TLS, require a client certificate, validate it against ca.crt, and forward the verified identity to the upstream application.
server {
listen 443 ssl;
server_name hooks.example.com;
ssl_certificate /etc/nginx/certs/server.crt;
ssl_certificate_key /etc/nginx/certs/server.key;
ssl_protocols TLSv1.3;
# Require and verify the client certificate against our private CA.
ssl_client_certificate /etc/nginx/certs/ca.crt;
ssl_verify_client on; # 'on' = mandatory; reject if absent/invalid
ssl_verify_depth 2;
location /webhooks/payments {
# Reject anything nginx did not successfully verify.
if ($ssl_client_verify != SUCCESS) { return 403; }
# Forward the verified identity to the app for tenant mapping.
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-Subject $ssl_client_s_dn;
proxy_pass http://127.0.0.1:8080;
}
}
With ssl_verify_client on, a caller presenting no certificate or an untrusted one fails during the TLS handshake — nginx never invokes the location block for it.
Step 4: Enforce identity in the application
Never trust the network alone: re-check the forwarded headers and resolve the subject to a tenant before processing. This also guards against a misconfiguration where the headers arrive from somewhere other than your trusted proxy.
from flask import Flask, request, abort
app = Flask(__name__)
# Map verified certificate subjects to tenant IDs.
SUBJECT_TO_TENANT = {
"CN=payments-provider,O=Example,OU=tenant-42": "tenant-42",
}
@app.post("/webhooks/payments")
def payments_webhook():
if request.headers.get("X-Client-Verify") != "SUCCESS":
abort(403, "client certificate not verified")
subject = request.headers.get("X-Client-Subject", "")
tenant = SUBJECT_TO_TENANT.get(subject)
if tenant is None:
abort(403, "unknown client certificate subject")
# Connection identity established. Now verify the payload signature
# (HMAC/JWT) before mutating state.
request.environ["tenant_id"] = tenant
return ("", 204)
Verification / Testing
Confirm an authenticated call succeeds and an unauthenticated one is rejected.
# 1. Inspect the handshake; expect "Verify return code: 0 (ok)".
openssl s_client -connect hooks.example.com:443 \
-cert producer.crt -key producer.key -CAfile ca.crt -tls1_3 </dev/null
# 2. Authenticated request -> 204
curl -sw '%{http_code}\n' https://hooks.example.com/webhooks/payments \
--cert producer.crt --key producer.key --cacert ca.crt \
-d '{"event":"payment.succeeded"}'
# 3. No client certificate -> handshake aborts / 400-level, NOT 204
curl -sw '%{http_code}\n' https://hooks.example.com/webhooks/payments \
--cacert ca.crt -d '{}' || echo "rejected as expected"
In CI, assert the third command fails: a test that authenticated traffic passes is incomplete without a test that anonymous traffic is refused.
Failure modes and gotchas
400 No required SSL certificate was sent— the client did not present a certificate. Confirm the producer is actually loadingproducer.key/producer.crt, and that no intermediate proxy is stripping the TLS session.SSL certificate verify failedon a valid certificate — nginx’sssl_client_certificatebundle is missing the issuing CA or an intermediate. Append the full chain toca.crtand raisessl_verify_depthif you sign through an intermediate.- Headers spoofable when the app is reachable directly — if a client can hit the app on port 8080 bypassing nginx, it can forge
X-Client-Verify. Bind the app to localhost only, and strip these headers at the proxy edge for any path nginx did not verify. - Silent expiry outage — a lapsed leaf certificate rejects 100% of deliveries at the handshake with no application log. Alert on
notAfterapproaching, and rotate with overlap as described in Mutual TLS for Webhooks.
Related
- Step-by-step HMAC webhook validation in Node.js — payload-level signing to pair with connection-level mTLS.
- Mutual TLS for Webhooks — the trust model, rotation, and pinning concepts behind this configuration.