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.

Layered mTLS enforcement for a webhook endpoint nginx terminates mTLS and verifies the client certificate against the private CA, then forwards the verified subject and verify status to the application which maps it to a tenant. Provider + client cert nginx ssl_verify_client on verify vs private CA fail closed App subject to tenant mTLS headers X-Client-Verify X-Client-Subject
nginx fails the handshake closed for any untrusted client certificate, then forwards the verified subject to the application for tenant mapping.

Prerequisites

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