Mutual TLS lets an API gateway verify a calling service’s certificate and proof of possession of its private key. A stolen API token alone need not pass that certificate boundary. The gateway still needs a defined trusted CA set, while the application needs authorization rules for resources and actions.
The client verifies the server’s certificate and hostname; Nginx or Caddy verifies the client credential. These two directions can use different issuers. mTLS does not automatically identify a human operator, prevent misuse of a compromised client key or authorize every request made by a valid certificate.
This guide uses the same CA workflow and Node.js authorization application in both languages. It covers incoming mTLS, a precise identity-header contract, a separate upstream mTLS connection, rotation and revocation. Examples assume maintained OpenSSL 3.x, TLS-enabled Nginx, Caddy 2.11.7 syntax and a supported Node.js release. Replace lab names, paths and network addresses for your environment.
What mTLS Authenticates and Authorizes
Service APIs, administrative endpoints and partner webhooks can use client certificates when callers support them. First identify which TLS endpoint verifies each peer and where application permissions are enforced. A reachable private network does not itself establish service identity; a valid certificate does not replace resource-level policy.
Server and client certificates are separate credentials and need not come from the same CA. The client never sends its private key; TLS proves possession of that key. This lab uses one private CA for simplicity, while production may need distinct issuers, an offline root and intermediate CAs for separate trust boundaries.
Use distinct service or device keys, verified enrollment and an explicit access inventory. Short-lived certificates reduce the remaining validity window but do not automatically rotate the key or block an exposed credential immediately. Plan effective revocation, application authorization removal and treatment of existing connections before rollout.
Create the Lab CA and Certificates
A small OpenSSL CA makes the mechanism inspectable. Preserve the issuance database, serial state and CRLs along with controlled CA-key backups. step-ca can automate enrollment and renewal, but its default passive revocation stops renewal rather than invalidating an issued certificate before expiry. Active enforcement must be configured separately.
Explicit OpenSSL CA, Client and Server Profiles
The following opens an exclusive new directory and stops if it already exists. Continue CA commands in that directory because dir = . uses the working directory. The root, clientAuth leaf and serverAuth/SAN profiles are explicit. api.example.test is a private test name, not a name for public Let’s Encrypt issuance. CA signing asks for the encrypted key’s password and confirmation.
set -eu
umask 077
mtls_dir="$HOME/service-mtls-lab"
mkdir "$mtls_dir"
cd "$mtls_dir"
mkdir certs crl newcerts private
: > index.txt
printf '1000\n' > serial
printf '1000\n' > crlnumber
cat > openssl.cnf <<'EOF'
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = .
database = $dir/index.txt
new_certs_dir = $dir/newcerts
serial = $dir/serial
crlnumber = $dir/crlnumber
private_key = $dir/private/ca.key.pem
certificate = $dir/certs/ca.cert.pem
default_md = sha256
default_days = 90
default_crl_days = 7
policy = client_policy
unique_subject = no
copy_extensions = none
x509_extensions = client_cert
crl_extensions = crl_ext
[ client_policy ]
commonName = supplied
[ req ]
distinguished_name = req_dn
prompt = no
x509_extensions = root_ca
[ req_dn ]
CN = Service mTLS Lab CA
[ root_ca ]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
[ client_cert ]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
[ crl_ext ]
authorityKeyIdentifier = keyid:always
[ server_cert ]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = serverAuth
subjectAltName = DNS:api.example.test,DNS:backend.example.test
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
EOF
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -aes-256-cbc -out private/ca.key.pem
openssl req -new -x509 -config openssl.cnf -key private/ca.key.pem -days 3650 -sha256 -out certs/ca.cert.pem
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private/client.key.pem
openssl req -new -key private/client.key.pem -subj "/CN=stock-service" -out client.csr.pem
openssl ca -config openssl.cnf -extensions client_cert -days 90 -notext -in client.csr.pem -out certs/client.cert.pem
openssl ca -config openssl.cnf -gencrl -out crl/ca.crl.pem
openssl verify -CAfile certs/ca.cert.pem -purpose sslclient -crl_check -CRLfile crl/ca.crl.pem certs/client.cert.pem
openssl x509 -in certs/client.cert.pem -noout -serial -dates
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private/server.key.pem
openssl req -new -key private/server.key.pem -subj /CN=api.example.test -out server.csr.pem
openssl ca -config openssl.cnf -extensions server_cert -days 90 -notext -in server.csr.pem -out certs/server.cert.pem
openssl verify -CAfile certs/ca.cert.pem -purpose sslserver -verify_hostname api.example.test certs/server.cert.pem
The CA database records the issued client and the initial CRL exists before Nginx references it. The first client serial in this fresh lab is 1000. Verify enrollment rather than trusting a CSR’s self-chosen CN. Install the server leaf/key, public CA and CRL on the proxy with appropriate permissions; never install the CA private key there. The server’s SAN must match the hostname the client actually verifies.
Connect with the Client Certificate
The test below assumes the proxy is on the local test machine and listens on 443. Change the –resolve address when testing another machine. This option overrides name resolution for that request while preserving the URL hostname and SNI for TLS verification. Keep the client private key on the test client.
curl --resolve api.example.test:443:127.0.0.1 --cacert certs/ca.cert.pem -i https://api.example.test/stock
curl --resolve api.example.test:443:127.0.0.1 --cacert certs/ca.cert.pem --cert certs/client.cert.pem --key private/client.key.pem -i https://api.example.test/stock
Test before revoking the lab credential: a request without a certificate must not reach the application, and the authorized client must reach the expected /stock response. Then test an untrusted certificate, a same-CA leaf outside the allowlist and the revoked or deauthorized credential. A successful 200 alone does not establish that the rejection boundary works.
Require Client Verification on Nginx
Nginx terminates the client TLS connection and can forward the verified leaf identity to an application. That forwarding is a second trust decision. The backend must accept identity headers only from the intended proxy and must prevent direct untrusted access. Do not treat an arbitrary X-Client header as proof of TLS authentication.
Complete Incoming Nginx Configuration
Install certs/server.cert.pem as /etc/mtls/server.crt, private/server.key.pem as /etc/mtls/server.key, certs/ca.cert.pem as /etc/mtls/ca.crt and the initial crl/ca.crl.pem as /etc/mtls/ca.crl. Grant only the necessary service processes access to private keys. The following goes inside the existing http block, with log_format outside server.
# Inside the existing http {} block:
log_format service_mtls '$remote_addr "$request" $status '
'verify="$ssl_client_verify" serial="$ssl_client_serial"';
server {
listen 443 ssl;
server_name api.example.test;
ssl_certificate /etc/mtls/server.crt;
ssl_certificate_key /etc/mtls/server.key;
ssl_client_certificate /etc/mtls/ca.crt;
ssl_verify_client on;
ssl_verify_depth 1;
ssl_crl /etc/mtls/ca.crl;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache off;
ssl_session_tickets off;
access_log /var/log/nginx/service_mtls.log service_mtls;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-Cert-Encoding uri-pem;
proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
}
}
This whole-host configuration requires a verified client for every route, including /healthz. Verification depth 1 fits the direct-root lab; reassess trust and required CRLs for intermediate chains. Session cache and tickets are disabled here to simplify fresh-handshake tests. If you enable reuse, assess its revocation behavior separately from established application connections.
An Additional Exact-Serial Gate
As an optional proxy gate under this one dedicated issuer, map the actual verified serial to an allow flag. The example serial 1000 is only the first lab client. This literal match avoids an unanchored subject regex that would also accept a longer, unintended CN or organization name.
# Optional additional gate, inside http {}; this single CA only:
map "$ssl_client_verify:$ssl_client_serial" $client_allowed {
default 0;
"SUCCESS:1000" 1;
}
# Inside each protected location before proxy_pass:
# if ($client_allowed = 0) { return 403; }
Place the map at http scope and enable its illustrated if-return check in each protected location. It does not enforce anything by itself. If multiple issuers are trusted, use an issuer-qualified identity rather than a serial alone. Standard Nginx does not provide the invented $ssl_client_s_dn_cn variable or a general set of SAN variables assumed by some examples; parse a supported certificate representation when needed.
Use a Defined Certificate-Header Contract
Nginx supplies URL-escaped PEM using $ssl_client_escaped_cert; Caddy below supplies base64 DER. Both overwrite the verification, encoding and certificate headers. The application decodes the declared format and authorizes the exact SHA-256 leaf fingerprint. Do not send multiline PEM directly in an HTTP header or confuse Nginx’s SHA-1 fingerprint variable with a SHA-256 application identity.
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-Cert-Encoding uri-pem;
proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
Create the controlled allowlist below and install it as /etc/service-auth/allowed-clients.json. The caller must not be able to edit it. Save the standalone Node.js example as app.mjs and run it through your service manager. It uses no Express dependency. Only the trusted proxy may reach the listener: loopback blocks remote access, but does not protect against untrusted local processes. Use an appropriately permissioned Unix socket, process/network isolation or separately authenticated transport when that distinction matters.
node --input-type=module - certs/client.cert.pem > allowed-clients.json <<'JS'
import { readFileSync } from 'node:fs';
import { X509Certificate } from 'node:crypto';
const cert = new X509Certificate(readFileSync(process.argv[2]));
console.log(JSON.stringify({ [cert.fingerprint256]: 'stock-service' }, null, 2));
JS
// Save as app.mjs. Only the trusted local proxy may reach this listener.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { X509Certificate } from 'node:crypto';
createServer((req, res) => {
const reply = (status, body) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
};
if (req.headers['x-client-verify'] !== 'SUCCESS') {
return reply(401, { error: 'Client authentication required' });
}
let cert;
try {
const value = req.headers['x-client-cert'];
const encoding = req.headers['x-client-cert-encoding'];
if (typeof value !== 'string' || value.length > 24000) throw new Error();
if (encoding === 'uri-pem') {
cert = new X509Certificate(decodeURIComponent(value));
} else if (encoding === 'base64-der' && /^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
cert = new X509Certificate(Buffer.from(value, 'base64'));
} else {
throw new Error();
}
} catch {
return reply(401, { error: 'Invalid identity encoding' });
}
let allowed;
try {
allowed = JSON.parse(readFileSync('/etc/service-auth/allowed-clients.json', 'utf8'));
if (!allowed || typeof allowed !== 'object' || Array.isArray(allowed)) throw new Error();
} catch {
return reply(503, { error: 'Authorization policy unavailable' });
}
const service = Object.hasOwn(allowed, cert.fingerprint256)
? allowed[cert.fingerprint256] : undefined;
if (service !== 'stock-service') return reply(403, { error: 'Not authorized' });
let path;
try { path = new URL(req.url, 'http://localhost').pathname; }
catch { return reply(400, { error: 'Invalid request target' }); }
if (req.method === 'GET' && path === '/stock') {
return reply(200, { service, status: 'authorized' });
}
if (req.method === 'GET' && path === '/healthz') return reply(200, { status: 'ok' });
return reply(404, { error: 'Not found' });
}).listen(3000, '127.0.0.1');
X509Certificate parsing does not independently validate the TLS chain; the application relies on the authenticated proxy boundary. This small example reads the policy on each request and fails closed if it is unavailable. For high traffic, use a controlled policy cache or authorization service with the same rejection behavior. Certificate renewal changes the fingerprint and requires a policy transition. Large certificate headers may exceed proxy or application header limits; test the actual leaf size.
Authenticate Nginx to a Separate Backend
A remote backend introduces a new TLS connection. Prepare a backend server certificate matching backend.example.test, the correct backend CA trust and a separate gateway client certificate with clientAuth. The backend must require and specifically authorize that gateway credential, then accept forwarded original-client identity only through that trusted connection.
# Replace the incoming server's location / with this example.
# Requires separately issued gateway client credentials and backend trust.
location / {
proxy_pass https://10.0.0.12:8443;
proxy_ssl_server_name on;
proxy_ssl_name backend.example.test;
proxy_ssl_trusted_certificate /etc/mtls/backend-ca.crt;
proxy_ssl_verify on;
proxy_ssl_verify_depth 1;
proxy_ssl_certificate /etc/mtls/gateway-client.crt;
proxy_ssl_certificate_key /etc/mtls/gateway-client.key;
proxy_ssl_protocols TLSv1.2 TLSv1.3;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-Cert-Encoding uri-pem;
proxy_set_header X-Client-Cert $ssl_client_escaped_cert;
}
Replace the incoming server’s location block with this example; retain incoming TLS verification. proxy_ssl_verify is explicitly enabled because its default is off. proxy_ssl_name sets the name to verify and proxy_ssl_server_name enables SNI. The gateway client credential identifies Nginx at the backend, while the forwarded header represents the original caller. A correct CA does not compensate for the wrong server name.
Record Verification and Authorization Separately
Record the proxy’s client-verification result and serial alongside the application’s authorization decision. The serial or fingerprint links to a credential inventory; shared keys weaken attribution to an individual service instance. Do not routinely log full certificate headers or private material.
# Already included above; do not define the format twice.
# log_format belongs in http {}, access_log in the relevant server {}.
log_format service_mtls '$remote_addr "$request" $status '
'verify="$ssl_client_verify" serial="$ssl_client_serial"';
access_log /var/log/nginx/service_mtls.log service_mtls;
Use restricted retention and access for identity-related logs. A TLS verification success followed by application 403 can be correct: the chain is trusted but the leaf is not authorized. Trace correlation helps diagnose that distinction without treating a request identifier as authentication.
Apply the Same Policy with Caddy
Caddy applies the same certificate boundary and sends a representation the same Node.js application accepts. The example uses manually supplied server certificate files; their renewal is your responsibility. Automatic HTTPS for a suitable public hostname is a separate deployment choice and does not make private .local or .test names publicly issuable.
Complete Incoming Caddy Configuration
This example uses the current trust_pool file syntax for Caddy 2.11.7. It loads the public CA trust and requires verified clients. The certificate and key are the same manually issued server files used by Nginx. All identity header_up directives belong inside reverse_proxy.
https://api.example.test {
tls /etc/mtls/server.crt /etc/mtls/server.key {
client_auth {
mode require_and_verify
trust_pool file {
pem_file /etc/mtls/ca.crt
}
}
}
reverse_proxy 127.0.0.1:3000 {
header_up X-Client-Verify SUCCESS
header_up X-Client-Cert-Encoding base64-der
header_up X-Client-Cert {tls_client_certificate_der_base64}
}
}
The actual placeholder {tls_client_certificate_der_base64} produces a single-line certificate encoding. The literal SUCCESS header is justified by mandatory require_and_verify for the whole site; there is no assumed tls_client_verified placeholder. A path-specific handle cannot remove a TLS requirement. Keep health routes protected or use a separate network-restricted monitoring listener that cannot bypass the application boundary.
Authenticate Caddy to a Separate Backend
To connect Caddy to the same separately protected backend, replace its reverse_proxy block with the following. Provision the gateway credential and backend trust first. The backend must identify the gateway as an authorized proxy before trusting its headers about the original client.
# Replace reverse_proxy inside the incoming mTLS site with this block.
# The backend must require and authorize the gateway client certificate.
reverse_proxy https://10.0.0.12:8443 {
header_up X-Client-Verify SUCCESS
header_up X-Client-Cert-Encoding base64-der
header_up X-Client-Cert {tls_client_certificate_der_base64}
transport http {
tls_server_name backend.example.test
tls_trust_pool file {
pem_file /etc/mtls/backend-ca.crt
}
tls_client_auth /etc/mtls/gateway-client.crt /etc/mtls/gateway-client.key
}
}
tls_trust_pool supplies backend server trust, tls_server_name selects the verified name and tls_client_auth loads the gateway client certificate and key. Caddy TLS client-authentication documentation describes the incoming side; the reverse_proxy transport has its own directive names. Do not substitute unsupported root_ca or client_certificate spellings or disable backend verification.
Rotate Certificates and Trust Deliberately
Track leaf certificates, issuing CAs, policy identities and the connections that consume them separately. A successful file copy is not proof of a successful rotation. Validate the new path and the intended rejection of obsolete credentials at each relevant proxy and application boundary.
Plan Lifetime and Renewal
The lab uses 90-day leaves; production lifetime should fit exposure and reliable renewal capacity. step-ca or another internal PKI can automate parts of that process. Explicitly rotate keys when needed: issuing a new certificate with the old public key is certificate renewal, not key replacement. Stopping renewal leaves a still-valid credential usable until another control removes its authority or it expires.
Protect Keys and Distribution
Keep the CA signing key off proxies and applications. Protect client and gateway keys for the exact runtime that needs them, use controlled read-only mounts where practical and avoid broad file permissions. Prefer generating client keys at their intended service and sending only an approved CSR for signing.
Coordinate CA Trust and Identity Policy
For a planned CA transition, stage only the required new trust alongside the old valid trust, then move credentials and verify before removing the old issuer. Multiple issuers can reuse a serial number, so revisit serial-only gates. Keep the application’s exact-leaf fingerprint policy aligned with the certificates actually deployed.
cat new-ca.crt old-ca.crt > clients-ca-bundle.crt
This bundle command assumes two already authenticated public CA files; it does not generate CAs or deploy trust. A similar controlled overlap can be needed for clients verifying server chains. CA compromise requires removal of the compromised authority, not a routine indefinite overlap with it. Test both accepted and rejected identities after the change.
Validate and Reload Manual Certificate Files
For Nginx, validate the configuration and perform the supported service reload. For manually loaded Caddy files, use caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile followed by caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile --force. Reload uses the protected, enabled admin API. –force can reload manual certificate files even if the config text is unchanged. Do not assume production Caddy watches arbitrary certificate files automatically. Validate the newly presented credential after loading it.
Enforce Emergency Access Removal
For an exposed key, remove its SHA-256 entry through an atomic update of the controlled application policy; an empty {} policy denies all services. Also revoke the certificate in the CA database. The CA commands below produce a new CRL; securely install that public file at Nginx’s /etc/mtls/ca.crl, test, reload and verify rejection on a new connection. Refresh before this profile’s seven-day nextUpdate even if no new revocations occur.
cd "$HOME/service-mtls-lab"
openssl ca -config openssl.cnf -revoke certs/client.cert.pem -crl_reason keyCompromise
openssl ca -config openssl.cnf -gencrl -out crl/ca.crl.pem
openssl crl -in crl/ca.crl.pem -noout -lastupdate -nextupdate
Stock Caddy’s basic trust_pool does not consume that Nginx CRL automatically. Use a supported additional certificate verifier or enforce current authorization in the application. In this example policy removal rejects the next request while the TLS chain can still verify. Existing TLS/WebSocket connections and application sessions require separate incident handling. Short lifetime alone is not immediate revocation.
Test Acceptance, Rejection and Forwarded Identity
Test the intended positive path and deliberate negative paths through both proxies. Record which layer rejects the request: TLS trust, certificate policy, application authorization or backend server validation. Keep private fixture keys out of logs and use credentials created for testing.
Compare Missing and Authorized Credentials
A request without a client certificate must not reach the protected application. Use the correct hostname and CA trust so a client-side server-trust failure is not mistaken for proof of client verification. Depending on the proxy and failure stage, rejection can occur before an HTTP response.
curl --resolve api.example.test:443:127.0.0.1 --cacert certs/ca.cert.pem -i https://api.example.test/stock
Before revocation, the authorized lab credential should receive the application’s expected /stock result. Test an untrusted client and a different valid same-CA leaf too; the latter must be denied by the application allowlist. Do not predict a single status for every certificate failure.
curl --resolve api.example.test:443:127.0.0.1 --cacert certs/ca.cert.pem --cert certs/client.cert.pem --key private/client.key.pem -i https://api.example.test/stock
After CA revocation and CRL deployment, test a fresh Nginx connection with the original files. For Caddy’s basic configuration, also remove the original SHA-256 policy entry and confirm application rejection. An already accepted long-running session has different lifecycle rules from these fresh request tests.
# Use the same credential after revocation/deauthorization; it must be rejected.
curl --resolve api.example.test:443:127.0.0.1 --cacert certs/ca.cert.pem --cert certs/client.cert.pem --key private/client.key.pem -i https://api.example.test/stock
Check Both Certificate Directions
Inspect the actual issuer, chain, lifetime, purpose and verified hostname on the failing connection. Nginx client-certificate documentation explains CA trust, chain depth and CRL handling. An intermediate deployment needs the intended trust and required CRLs, not simply an arbitrary larger depth. Retain server verification while testing.
Inspect the Application Decision
The sample /stock response provides a limited confirmation of the authorized service mapping. Send fake incoming identity headers with a valid client and verify that the proxy replaces them; send a different valid leaf and verify that it cannot select the approved identity. Avoid exposing general certificate-debug routes publicly.
{
"service": "stock-service",
"status": "authorized"
}
Choose the Authorization Boundary
Define who approves certificate enrollment, who assigns service permissions and how urgent removal reaches each enforcement point. Those are separate responsibilities. An operationally complete design includes monitoring for policy-file failure, CRL expiry and failed trust deployment as well as successful issuance.
The gateway can enforce broad admission and the application can enforce resource-level rules. This example pins an exact SHA-256 leaf fingerprint to stock-service; it does not grant authority based on a suggestive CN substring. More complex policy may use a CA-controlled identity and an authorization service, provided the complete trust path is verified.
Keep an inventory of service, environment, issuer, serial, leaf fingerprint, expiration and ownership. Stable service names aid operations, but requested names must be authorized by enrollment policy. A SAN URI such as a SPIFFE ID is not validated merely by appearing in a certificate; the issuing system and consuming policy must implement its intended trust rules.
Monitor verification failures separately from policy denials, and correlate them with the selected route and backend. Do not log private keys or unrestricted raw certificate headers. Expiry and nextUpdate alerts need a responsible owner and enough lead time to repair a failed distribution.
A public token-authenticated API and a private mTLS API can coexist under deliberate boundaries. TLS policy is chosen before the HTTP path is known. A separate hostname is often easier to reason about; if using optional verification with path-level enforcement, inventory every protected route and alternative backend path.
Apply the Pattern to Webhooks and Internal Calls
A partner webhook can use mTLS if the sender supports client credentials. Payload signatures, replay protection and rate controls address additional properties; mTLS does not provide all of them automatically. Bind the verified sender identity to the allowed webhook actions and manage the sender’s credential lifecycle.
For internal service calls, each hop needs its own configured server verification and client identity when mTLS is required. A gateway’s upstream certificate identifies the gateway, while original-caller identity is delegated through an explicitly trusted mechanism. Do not describe one verified ingress handshake as automatic end-to-end authentication for every backend.
Use the Related Admin-Panel Pattern
Protecting admin panels with Nginx mTLS covers the related user/device access pattern, including whole-host versus path checks, an initial CRL and controlled recovery. Keep application authentication and roles there as well. A health or rescue route inside a mandatory TLS host cannot bypass its certificate handshake requirement.
Diagnose Configuration and Lifecycle Failures
Begin diagnosis by locating the failing hop and whether it reached HTTP. A server-name error, missing client certificate, revoked leaf, unavailable policy and deliberate application 403 call for different fixes. Preserve the actual error rather than weakening verification to make a request pass.
Check issuer trust, the presented chain, verification depth, purpose constraints, certificate/CRL dates and the allowlist identity. The lab uses explicit CA:FALSE and separate clientAuth/serverAuth leaf profiles. Missing an EKU extension is not identical to an explicit restriction; inspect the actual certificate and validator policy instead of assuming every failure is the CA file.
For an upstream failure, compare the actual backend SAN with proxy_ssl_name or tls_server_name and verify the intended CA chain. The gateway credential must also be accepted by the backend’s client policy. These are separate validation directions, and a successful TCP connection proves neither.
If identity fields are missing, verify the exact variables, header placement and encoding contract. Nginx’s escaped PEM and Caddy’s DER base64 are decoded differently by the sample app. Repeating header directives at a lower Nginx level affects inheritance, so preserve the entire required header set when changing a location. Test certificate size against real header limits.
Connection reuse can reduce handshake work, but measure the actual latency and capacity impact. It also means authorization and revocation need a defined policy for existing connections and sessions. Do not promise negligible impact for every workload or continuous certificate revalidation for every message on one established connection.
Verify the Complete Service Access Boundary
The completed boundary admits the intended service and rejects missing, untrusted and unauthorized credentials at the appropriate layer. Nginx and Caddy feed the same explicit application identity policy through different documented certificate encodings. A separate backend connection verifies the backend server and authorizes the gateway client before trusting delegated caller information.
Repeat acceptance and rejection tests after CA, certificate, proxy, DNS, network or application-route changes. Check that the new leaf is authorized and the old one is no longer accepted. Keep CRL distribution, current application policy and open-session handling visible during rotations and incidents.
Reliable mTLS depends on enrollment, key protection, trust, authorization and revocation continuing to work together. Document the operational owner for each step and verify the actual traffic paths. A correct initial handshake is the beginning of that lifecycle, not its completion.





