Mutual TLS adds a client-certificate check before requests reach a protected admin application. Nginx requests a certificate and proof of possession of its private key, then verifies the certificate against configured trust. This can keep unauthenticated login attempts away from the application, while the network listener remains reachable. Rate limiting, application authentication and role checks still serve separate purposes.
This guide uses the same OpenSSL CA, certificate, CRL and Nginx workflow as its Turkish version. It targets a Linux lab with maintained OpenSSL 3.x and TLS-enabled Nginx. Example hostnames, server certificate paths and the loopback application on port 8080 must be replaced with your actual setup. Plan both issuance and revocation before requiring certificates in production.
What mTLS Protects at an Admin Gateway
With ordinary HTTPS, the client verifies the server certificate and hostname. mTLS adds server-side verification of the client certificate and its private-key proof. The two directions use separate credentials and trust stores. A trusted client certificate identifies a credential; it does not automatically authorize every action or prove who is currently using the device.
Nginx must guard every route to the admin application for this boundary to hold. Direct backend access, alternative listeners and uncovered admin APIs can bypass the intended check. Keep application login, MFA where required and least-privilege roles. Issue separate credentials per person or device so that one compromised key can be withdrawn without replacing everyone’s credential.
Choose the Client CA and Trust Boundary
An OpenSSL CA is useful for learning the mechanism or managing a small controlled deployment. Its operation still needs approved enrollment, protected signing keys, a consistent certificate database, reliable CRL distribution and accountable ownership. A CA service may be more suitable when device enrollment and renewal need sustained automation.
The lab below uses a CA dedicated to client authentication. Its encrypted private key remains on the CA machine; Nginx receives only the public CA certificate and CRL. Nginx’s HTTPS server certificate is separate and must be valid for the hostname and trusted by clients. Do not treat the long-lived lab root as a complete production PKI design.
Record the user, device, serial number, expiration and permitted role for each credential. Prefer a new key on a replacement device over copying the same PKCS#12 file everywhere. Define offboarding and recovery before rollout. If production requires an offline root and intermediate issuers, redesign chain depth, revocation files and signing procedures for that hierarchy.
Create a Client CA and Certificate with OpenSSL
Step 1: Create an Exclusive Lab Directory
Run the following in a shell on the intended CA lab machine. The directory is deliberately created without -p: an existing directory causes the command to stop rather than overwrite a CA database. Continue the following CA commands in this directory. The restrictive umask protects newly created keys and state.
set -eu
umask 077
mtls_dir="$HOME/mtls-ca-lab"
mkdir "$mtls_dir"
cd "$mtls_dir"
mkdir certs crl newcerts private
: > index.txt
printf '1000\n' > serial
printf '1000\n' > crlnumber
Step 2: Define the CA, Client and CRL Profiles
The configuration uses dir = ., so its paths are relative to the current working directory, not a shell-expanded tilde. The serial and CRL-number files are initialized before use. The root can issue only end-entity certificates under this profile; the client profile explicitly permits TLS client authentication and does not copy arbitrary CSR extensions.
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 = Admin Client 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
EOF
Step 3: Generate the Protected CA Key
The CA key-generation and signing operations ask for a passphrase. Keep the key, passphrase and consistent CA database backups under controlled access. Do not copy this private key to Nginx. A CA root certificate is public, but its fingerprint and the process that installs it as trusted material must be authenticated.
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
Step 4: Generate a Client Key and CSR
This demonstration generates Alex’s key locally in the protected lab directory. For production, prefer generating the private key on the intended device and sending only its CSR to the CA. Verify the enrollment request and ownership before signing; the requested subject text alone is not proof of identity.
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private/alex.key.pem
openssl req -new -key private/alex.key.pem -subj "/CN=Alex Admin" -out alex.csr.pem
Step 5: Issue the Certificate and Initial CRL
openssl ca records issuance in the database used for later revocation. Review its confirmation prompts. The first client in this fresh lab receives serial 1000; read the actual serial rather than assuming it in an existing CA. Generate the initial CRL now, even though it contains no revoked certificates, so that the file exists before Nginx references it.
openssl ca -config openssl.cnf -extensions client_cert -days 90 -notext -in alex.csr.pem -out certs/alex.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/alex.cert.pem
openssl x509 -in certs/alex.cert.pem -noout -serial -dates
Step 6: Export the Client PKCS#12 Package
The PKCS#12 package includes the client private key. Enter a strong export password at the terminal. Deliver the package through a restricted, time-limited secure channel and deliver the password separately through a verified channel. Keep only necessary key copies; changing the package password later does not invalidate an already copied private key.
openssl pkcs12 -export -inkey private/alex.key.pem -in certs/alex.cert.pem -certfile certs/ca.cert.pem -out alex.p12
Import the package into the browser’s or platform’s personal client-certificate store. Browser policy may select it automatically or offer a chooser. Importing a client credential does not require broadly trusting this CA to authenticate arbitrary websites. Browser trust in the HTTPS server certificate and Nginx trust in the client CA are separate directions.
Configure Nginx Client Verification
Prepare the public CA certificate and initial CRL on the intended Nginx server, then install them with the following commands from the directory containing those files. If the CA is another machine, securely transfer only these public files first. The CA private key stays on the CA machine. Install a separate valid server certificate for the actual hostname and ensure the backend is reachable only through authorized proxy paths.
sudo install -d -m 0755 /etc/nginx/client_ca
sudo install -m 0644 certs/ca.cert.pem /etc/nginx/client_ca/ca.cert.pem
sudo install -m 0644 crl/ca.crl.pem /etc/nginx/client_ca/ca.crl.pem
ssl_verify_client belongs in http or server scope, never location. A dedicated admin hostname is the simpler whole-host boundary. For a shared hostname, the certificate is still requested during TLS before the HTTP path is known; the path check happens afterward.
Dedicated Admin Host with Mandatory Verification
# Inside the existing http {} block:
log_format mtls '$remote_addr [$time_local] "$request" $status '
'verify="$ssl_client_verify" serial="$ssl_client_serial"';
# Inside the existing http {} block, after log_format:
server {
listen 443 ssl;
server_name admin.example.com;
ssl_certificate /etc/ssl/certs/admin-server-fullchain.pem;
ssl_certificate_key /etc/ssl/private/admin-server.key;
ssl_client_certificate /etc/nginx/client_ca/ca.cert.pem;
ssl_verify_depth 1;
ssl_crl /etc/nginx/client_ca/ca.crl.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache off;
ssl_session_tickets off;
access_log /var/log/nginx/admin_access.log mtls;
ssl_verify_client on;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-Serial $ssl_client_serial;
}
}
Place these snippets inside your existing http {} block, with log_format outside server. This example directly trusts the lab root and uses verification depth 1. Session caching and tickets are disabled to make fresh-handshake revocation tests clear; assess revocation and resumption explicitly before enabling reuse. Replace the server certificate paths and use an actual loopback-only backend. All requests on this host need a valid trusted client certificate.
The following is an alternative server configuration for a shared host. It uses optional at server scope, then requires verification success on both exact /admin and the /admin/ subtree. Public clients without certificates can reach public locations, but may receive a certificate prompt. A presented invalid certificate can still cause even a public request to fail. The server certificate must cover example.com.
# Alternative to the dedicated host; inside existing http {}:
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/certs/public-server-fullchain.pem;
ssl_certificate_key /etc/ssl/private/public-server.key;
ssl_client_certificate /etc/nginx/client_ca/ca.cert.pem;
ssl_verify_depth 1;
ssl_crl /etc/nginx/client_ca/ca.crl.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache off;
ssl_session_tickets off;
access_log /var/log/nginx/admin_access.log mtls;
ssl_verify_client optional;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-Serial $ssl_client_serial;
}
location = /admin {
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-Serial $ssl_client_serial;
}
location ^~ /admin/ {
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Client-Verify $ssl_client_verify;
proxy_set_header X-Client-Serial $ssl_client_serial;
}
}
The location-level if only returns a denial on failed verification; it does not try to renegotiate TLS. ^~ prevents a later regular-expression location from taking over that matched subtree. Enumerate all actual admin APIs, login routes, rewrites and alternative listeners: these two example paths do not cover every application. A map can calculate a decision, but still needs an explicit enforcement check.
The Nginx client-certificate directive reference documents the allowed scopes and verification modes. Do not use optional_no_ca as trusted client authentication unless a separate verifier actually performs the required validation. If you add intermediates, review the complete trust chain, verification depth and corresponding CRLs.
Stage the Rollout and Verify Recovery Access
Use a staging hostname to check successful access and deliberate rejection before changing production. Include a second authorized device, an untrusted certificate, a revoked certificate and no certificate. For the shared-host model, test public paths, exact /admin, descendants and every application-specific admin entry point.
Verify independent recovery through controlled SSH, a provider console or an appropriately secured management VPN. A public basic-auth side door weakens the intended boundary. Mandatory host-level TLS verification cannot be disabled inside a rescue location because the path is not known when TLS requests the certificate. Keep recovery on the separate controlled management channel.
Run nginx -t before a supported service reload and check the new workers accepted the configuration. Nginx can map its internal 495/496 certificate errors to a clear denial page, but some TLS failures occur before an HTTP response. Do not turn verification off to make an error page or health check reachable. Back up the CA key and its consistent issuance/revocation database.
Move Certificate Operations to a CA Service
step-ca documentation covers enrollment and short-lived certificate workflows. Its default passive revocation prevents renewal; it does not invalidate an already issued certificate before expiry. Active revocation and delivery of a CRL that Nginx consumes need an explicitly supported setup. A general CA-service deployment does not automatically replace the file-based revocation process shown here.
Revoke Certificates and Refresh the CRL
Track expiry, replacement devices, user departures and key compromise as separate events. The example client lifetime is 90 days; choose a lifetime that fits your risk and renewal capacity. Renew before expiration, verify the new credential and update any serial-based allowlist. Do not wait for expiry after a key is lost or exposed.
For the example of a compromised Alex key, revoke the actual certificate file in the CA database and produce a new CRL. The keyCompromise reason is specific to that event. Run CA commands on the CA machine, then securely transfer the public CRL to the Nginx machine and run its deployment commands there. No CA private key is needed on the proxy.
cd "$HOME/mtls-ca-lab"
openssl ca -config openssl.cnf -revoke certs/alex.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
sudo install -m 0644 crl/ca.crl.pem /etc/nginx/client_ca/ca.crl.pem.new
sudo mv /etc/nginx/client_ca/ca.crl.pem.new /etc/nginx/client_ca/ca.crl.pem
sudo nginx -t && sudo systemctl reload nginx
Refresh and distribute the CRL before nextUpdate even if no additional certificates have been revoked. This profile uses seven days, so schedule monitored refreshes earlier. After a successful Nginx test and reload, test a new TLS connection. Existing connections, session resumption and application sessions need their own invalidation policy; a graceful reload does not instantly disconnect everyone. Generate a new device key for replacement, rather than repackaging the compromised one.
Test Accepted and Rejected Requests
Run the following from the directory containing the client files, replacing the hostname. Test Alex’s acceptance before revoking it, then repeat with the same files after revocation and CRL deployment to verify rejection. A request without a certificate must not reach the protected application. A valid certificate may produce a login redirect instead of 200. Test an untrusted credential too, keeping server certificate and hostname verification enabled throughout.
curl -i https://admin.example.com/
curl -i --cert certs/alex.cert.pem --key private/alex.key.pem https://admin.example.com/
curl -i --cert-type P12 --cert alex.p12 https://admin.example.com/
The P12 command applies to a curl TLS backend that supports loading that format; it asks for a password rather than placing it in shell history. Schannel-based curl uses certificate-store references instead. For a private server CA, supply the correct --cacert rather than -k. Correlate failures with Nginx verification and serial logs; a client-side server-trust failure alone does not prove mTLS enforcement.
Diagnose Certificate and Routing Failures
First identify which TLS connection failed, which peer rejected the other and whether the request reached HTTP. Missing client credentials, an untrusted server, an expired CRL, an excluded serial and an application authorization failure are different causes. Preserve the actual error and selected listener instead of guessing from one HTTP code.
A missing required client SSL certificate means the expected credential was not supplied to that TLS endpoint. Check the personal certificate store, private key, browser profile, validity and clientAuth usage. A browser may select silently, so the absence of a chooser is not conclusive. Verify whether a CDN or load balancer terminates the TLS connection before Nginx.
A verification failure can involve CA trust, chain depth, expiration, usage constraints or revocation. Inspect the actual presented chain and Nginx error log. The lab’s direct root-to-client chain differs from an intermediate-CA deployment. Add only the intended trust and required chain material; broadly trusting unrelated issuers can expand who is admitted.
If revocation appears ineffective, confirm the serial, CA database entry, new CRL contents, nextUpdate and the exact path loaded by Nginx. Confirm the reload succeeded and use a fresh connection with the revoked credential. Then check session reuse and established application/WebSocket sessions separately. A CRL refresh does not itself close an already authorized long-running connection.
Use openssl s_client when a handshake trace is needed, while specifying the expected hostname and proper server trust. Compare that observation with a browser and a new curl invocation. Do not infer success from a TCP connection alone or from a certificate selection dialog without checking the application result.
Authorize Specific Certificate Identities
Successful certificate verification authenticates a credential accepted by the trusted CA set. Authorization decides whether that credential may enter this admin application. Nginx exposes verification state and serial number; its subject and issuer DN strings use a documented format. A substring such as “Admin” in a CN is not a sound authorization policy.
For this single dedicated CA, the following literal map admits only the verified certificate with serial 1000. Confirm the real serial with openssl x509 and keep a user/device inventory. Add the map once at http scope, then enable the illustrated if-return check in each protected location. The map alone does not enforce access.
# Inside the existing http {} block, for this dedicated CA only:
map "$ssl_client_verify:$ssl_client_serial" $mtls_admin {
default 0;
"SUCCESS:1000" 1;
}
# Add inside each protected location, before proxy_pass:
# if ($mtls_admin = 0) { return 403; }
Renewal creates a new serial, so update the allowlist through an approved transition and remove obsolete authority. If multiple CAs are trusted, a serial alone is not unique; use a verified issuer-and-serial identity or a proper authorization service. Keep the CA dedicated to the intended boundary and preserve application roles. Removing a map entry still requires a successful reload and consideration of existing connections.
Keep Application Authentication and Other Controls
Retain application authentication, MFA where appropriate, rate limits and least-privilege permissions after mTLS. Network or VPN restrictions can complement the certificate check. satisfy any combines the access/auth modules listed in Nginx documentation; it is not a general bypass for mandatory TLS client verification and should not be presented as a certificate rollout escape hatch.
VPS SSH hardening with hardware keys and SSH certificates covers a separate management access channel. Some operational habits, such as protected keys, clear ownership and planned rotation, apply to both. An SSH certificate is not an X.509 client certificate, and its trust configuration does not replace the Nginx mTLS setup.
Understand Proxy Termination and Long Connections
If a CDN or load balancer terminates client TLS, the end-user mTLS relationship ends there. The origin sees a new connection from the proxy, not the original client’s handshake. Edge-verified identity needs a trusted forwarding mechanism and separately authenticated backend transport. True TLS passthrough can leave verification at Nginx, but only if the intermediary does not terminate that TLS connection.
The proxy examples overwrite X-Client-Verify and X-Client-Serial with Nginx-derived values. The backend must accept those headers only from the trusted proxy and prevent direct untrusted access. WebSocket proxying needs its own upgrade configuration; mTLS does not configure it. HTTP/2 and long-lived connections do not continuously recheck certificate revocation for each message, so plan how existing sessions are closed during an incident.
HSTS directs clients to HTTPS; it does not authorize a client. Roll it out with a short initial policy and verify all affected hostnames before considering includeSubDomains or preload. Removing the header does not instantly erase browser-cached policy or preload entries. Keep this lifecycle separate from client-certificate renewal and revocation.
Use the Directive and Certificate References
Use the Nginx client-certificate reference for directive scope, trust and variables, and the OpenSSL PKCS#12 reference for package behavior. The linked OpenSSL page is versioned; check your maintained installed version when selecting options. A reference link does not validate an untested deployment or a mismatched platform certificate store.
Maintain Keys, Trust Files and Access Records
Monitor client and CA expiry, CRL nextUpdate, signing-key access and deployment failures. Document enrollment, device replacement, revocation and independent recovery. Protect logs as identity-related data and never log private keys or package contents. A serial identifies the issued credential; shared keys make individual attribution unreliable.
For larger deployments, separate authenticated enrollment from policy decisions. A dedicated authorization service can map a verified issuer/serial identity to current roles; Nginx auth_request is one integration option when the module and service are properly configured. That service must receive identity only from the trusted proxy and deny access when required validation cannot be completed.
Verify the Complete Admin Access Boundary
The intended result is measurable: an authorized valid certificate reaches the correct application flow, while missing, untrusted, revoked or disallowed credentials do not reach protected routes. In the shared-host model, public behavior must remain as designed. Test every relevant listener and entry point rather than treating one successful browser visit as complete verification.
Start in the isolated lab, stage the actual route configuration, establish the initial CRL and trust files, and validate both acceptance and rejection before rollout. Assign renewal, CRL distribution and incident owners. Use the separate controlled management channel for recovery; do not keep a public bypass as part of the normal access design.
Repeat the checks after DNS, proxy, certificate, Nginx or application-route changes. Keep old serial authority, current CRL state and established sessions visible during rotations and incidents. A reliable mTLS boundary depends on the continuing operation of issuance, authorization and revocation as well as the initial TLS configuration.
Reference Links
Nginx client-certificate directives
OpenSSL PKCS#12 usage
step-ca overview





