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

One prerequisite is architectural rather than a package version: confirm that nothing between the producer and nginx terminates TLS. A layer-7 cloud load balancer, a CDN in full-proxy mode, or a service mesh ingress will happily accept the connection, decrypt it, and open a fresh one to nginx without any client certificate — the endpoint then behaves exactly as it did before, which is why this misconfiguration usually ships unnoticed. Either put the balancer in TCP passthrough mode so the handshake reaches nginx intact, or move the client-verification configuration up to the balancer and have it forward the same identity headers Step 3 sets. Deciding this before you issue any certificates saves reissuing them against the wrong hostname later.

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.

Two choices in that command are worth understanding rather than copying. The 4096-bit RSA key is deliberately oversized for a root: it signs rarely, so the extra cost is irrelevant, and it will outlive several generations of leaf certificates. The five-year validity is a compromise — long enough that you are not re-bootstrapping trust every year, short enough that it will expire while someone who knows what it is still works there. Put a calendar reminder at four years, not four years and eleven months, because replacing a root means touching every verifier at once.

For anything beyond a single endpoint, do not sign leaves directly with this root. Create an intermediate CA, keep the root key offline — an encrypted volume in a safe, a hardware token, or an air-gapped host — and distribute only the intermediate to nginx. The practical benefit shows up during an incident: if the intermediate key leaks, you issue a new intermediate from the offline root and every verifier keeps working after a bundle swap, whereas a leaked root means rebuilding trust from scratch on every consumer. If your CA tooling supports name constraints, add them to the intermediate so it can only issue for identities under a namespace you control; that turns a stolen signing key from a fleet-wide compromise into a scoped one.

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 — the cutover procedure is in rotating client certificates for mTLS webhooks. Every field of that subject has a job, and the OU is what Step 4 turns into a tenant.

Encoding tenancy in the certificate is what makes the rest of the design cheap. The alternative — matching on the common name in application code, or trusting a tenant identifier from the request body — either couples your authorization logic to a human-readable label that partners eventually ask you to change, or accepts an attacker-controlled value. The OU is a stable, CA-attested string that only the CA operator can set, so an authenticated caller cannot promote itself to another tenant by editing a payload. Where your tooling supports it, prefer a subject alternative name URI such as spiffe://webhooks/tenant/42 over the OU: SAN parsing is unambiguous, whereas distinguished-name string formatting varies between terminators and has produced more than one production mismatch where nginx emitted the components in a different order than the application expected.

The 90-day lifetime is the right default for a manually delivered certificate and the wrong one for an automated pipeline. Ninety days is short enough that a leaked key has a bounded useful life and long enough that a human rotation ritual runs four times a year rather than weekly. If you can automate issuance and reload — the consumer runs cert-manager, or you can hand them an ACME endpoint — drop to 24 or 72 hours instead, at which point revocation stops being a problem you need to solve. Whatever you pick, record the notAfter in the same inventory that holds your other expiring credentials on the day you issue it, because the certificate itself is the only other place that date exists and nothing queries it by default.

Anatomy of the client certificate subject The subject distinguished name splits into common name, organization and organizational unit, with the organizational unit carrying the tenant identifier the application maps. Subject DN issued in Step 2, field by field CN=payments-provider O=Example OU=tenant-42 identity of the caller; log it on every verified handshake issuing org; informational only the tenant key the app maps in SUBJECT_TO_TENANT; only trusted when X-Client-Verify is SUCCESS
nginx exposes this whole string as $ssl_client_s_dn once the chain validates; the OU field is the only part the application authorizes on.

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.

The if ($ssl_client_verify != SUCCESS) guard looks redundant next to on, and on this server block it is. Keep it anyway: it is what makes the block safe to copy into a server that later adds optional for a different path, and it is the single line that turns an accidental downgrade into a 403 instead of an accepted anonymous request. ssl_verify_depth 2 allows one intermediate between the leaf and the trusted root; raise it only if you actually sign through a longer chain, because a permissive depth widens what a compromised sub-CA can present.

Choosing the right client verification mode

The ssl_verify_client directive has four values and they differ in where the rejection happens, which changes what you can observe and what you can roll back. on rejects at the TLS layer, so there is no request to log and no HTTP status to chart — excellent for steady state, useless for finding out who would break before you enforce. optional still fails the handshake for an untrusted certificate but lets a caller with no certificate through, setting $ssl_client_verify to NONE; that is the mode to run during a rollout, paired with logging rather than rejection. optional_no_ca accepts anything and defers the entire trust decision to your application, which is only appropriate when a downstream service holds the trust store.

nginx client verification modes compared Four criteria compared across the on, optional and optional_no_ca settings, showing where each mode rejects a caller and which phase of a rollout it suits. Where each mode rejects the caller Criterion on optional optional_no_ca Caller sends no cert handshake aborts request proceeds, verify is NONE same as optional Untrusted issuer handshake aborts handshake aborts accepted, verify is FAILED Rejection happens at the TLS layer your location block your application Use it during steady state rollout observation delegated trust only Only the highlighted column fails closed without any application code involved.
Run optional while you measure who actually presents a certificate, then move to on — the mode where no application bug can accept an anonymous caller.

Sequence the change accordingly: deploy with optional and a log line recording $ssl_client_verify per request, watch for a full week including a low-traffic weekend, and only flip to on once every expected identity shows SUCCESS on every call. Keep the directive in a small include file so the rollback is a one-line edit plus nginx -s reload, which takes about a second and drops no in-flight requests. Baking the mode into a container image turns that rollback into a redeploy, and a redeploy during a total delivery outage is the wrong time to discover your image build takes twelve minutes.

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)

Three properties of that handler are load-bearing. It rejects rather than defaults when the subject is unknown, so adding a partner is an explicit map change instead of a silent grant. It checks the verify header before reading the subject, so a proxy that forwards a subject without a verify result cannot slip through. And it stops at establishing identity — the payload signature check still has to run before any state change, because a correctly authenticated connection can still carry a body that was rewritten inside your own network or replayed from an earlier capture.

The map itself is the piece that outgrows the code fastest. A dictionary is right for a handful of partners: changes go through review, and the deploy is your audit log. Once onboarding happens more often than quarterly, move it to a table keyed on the exact distinguished-name string with the tenant as the value, and cache it in process with a short refresh interval so a database outage does not become a delivery outage. Whichever storage you choose, normalise the DN the same way on both sides — nginx renders $ssl_client_s_dn in RFC 2253 order, which reverses the component order some other terminators use, and a mismatch here presents as every request being rejected with a subject that looks correct to the human reading the log.

Verification and 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.

Add a fourth case that most teams skip — a certificate signed by a different CA. Generate a throwaway root and leaf once, commit them as test fixtures, and assert that the call is rejected. Without it, a regression that swaps ssl_client_certificate for a bundle including a public root passes every other test in the suite, because your legitimate certificate still validates. Round the suite out with an expiry assertion so a certificate that is about to lapse fails the build rather than production:

# 4. A leaf from an untrusted CA must be refused, not merely logged.
curl -sw '%{http_code}\n' https://hooks.example.com/webhooks/payments \
  --cert rogue.crt --key rogue.key --cacert ca.crt -d '{}' \
  && echo "FAIL: untrusted leaf accepted" || echo "rejected as expected"

# 5. Fail the build if the client leaf expires within 21 days (1814400 seconds).
openssl x509 -in producer.crt -noout -checkend 1814400 \
  || { echo "FAIL: producer.crt expires within 21 days"; exit 1; }

# 6. Print the exact subject nginx will forward, to seed SUBJECT_TO_TENANT.
openssl x509 -in producer.crt -noout -subject -nameopt RFC2253

Run cases 5 and 6 on a schedule as well as on every commit. A build that only runs when someone changes code will not warn you about a certificate expiring during a quiet quarter, which is precisely when the expiry will land.

Failure modes and gotchas

Almost every mTLS defect resolves to one binary question — did the handshake complete? — and the branch you land on tells you whether to look at the certificate, the trust chain, or the proxy headers.

Diagnosing a rejected mTLS delivery Branch first on whether the TLS handshake completed, then on the nginx log line or the forwarded verify header, to reach one of four concrete fixes. TLS handshake completed? no yes nginx logged: no required SSL certificate was sent X-Client-Verify header equals SUCCESS? yes no no yes No cert presented: confirm the producer loads producer.key and producer.crt Chain incomplete: append the issuing CA and raise ssl_verify_depth Header missing: the proxy is not forwarding ssl_client_verify Subject unknown: add the DN to the tenant map or reissue it
Split on the handshake first: a TLS-level failure is a certificate or trust-chain problem, an HTTP-level 403 is a proxy-header or tenant-mapping problem.

Frequently Asked Questions

Can I reuse my existing public TLS certificate as the client certificate?

You can technically present any certificate whose key you hold, but a publicly issued server certificate is the wrong credential here. Its private key usually lives on every edge node that terminates traffic, so the identity it proves is much broader than a single dispatcher. Issue a dedicated leaf whose key exists only where the dispatcher runs, and give it the client-authentication extended key usage.

Does this configuration work behind a cloud load balancer?

Only if the balancer either passes TCP through untouched or performs the verification itself and forwards the result. A layer-7 balancer that terminates TLS without client verification silently converts your mutually authenticated endpoint into an anonymous one. Configure passthrough on the balancer, or move the trust bundle up to it and have it inject the same identity headers nginx would.

Why does openssl s_client succeed while curl fails with the same files?

The usual cause is that s_client completes the handshake and stops, while curl also performs hostname verification and follows the full HTTP exchange. A certificate whose subject alternative name does not cover the hostname passes the first and fails the second. Compare the SAN list against the URL you are calling before assuming the server configuration is at fault.

Should the subject-to-tenant map live in code or in a database?

Keep it in code while you have a handful of partners, because a deploy-gated change is an audit trail and a review gate for free. Move it to a table once onboarding happens more than a few times a quarter, and index it on the exact distinguished name string. Whichever you choose, treat an unmatched subject as a rejection rather than falling back to a default tenant.

How do I test the rejection path without a real untrusted certificate?

Generate a second self-signed CA and a leaf under it, and keep both in your test fixtures. Calling the endpoint with that leaf exercises the untrusted-chain branch, while calling with no certificate at all exercises the missing-certificate branch. Both assertions belong in continuous integration, because a regression that accepts either one is invisible to a happy-path test.

What happens to in-flight requests when I reload nginx after a certificate change?

A reload starts new worker processes with the new configuration and lets the old workers finish their existing connections, so requests already in flight are not dropped. The subtlety is that long-lived keep-alive connections stay pinned to the old workers until they close, which means a revoked certificate can keep working for the duration of your keep-alive timeout.