Mutual TLS explained: when both sides present a certificate
In ordinary TLS only the server proves who it is. The client verifies the server's certificate, a session key is agreed, and the client's identity is established afterwards at the application layer, by a password, an API key, or a bearer token. Mutual TLS moves that second identity into the handshake: the server asks the client for a certificate too, and refuses the connection outright if the client cannot present a valid one. Authentication happens before a single byte of HTTP is exchanged.
What changes in the handshake
A normal TLS handshake has the server send its certificate and prove possession of the matching private key. In mTLS the server additionally sends a CertificateRequest, usually naming the certificate authorities it will accept. The client responds with its own certificate chain and a signature proving it holds the corresponding private key. The server validates that chain against its configured trust store exactly as a browser validates a server certificate: is it signed by a CA I trust, is it within its validity window, has it been revoked, and does it satisfy whatever additional rules I apply.
Fail any of those and the connection is refused at the transport layer. There is no request to log, no endpoint reached, no application code executed. That property is most of the appeal: unauthenticated traffic never touches your service.
The trust store on the server side is normally your own internal CA, not the public roots. Accepting any publicly trusted client certificate would mean accepting certificates from millions of unrelated parties, which authenticates nothing useful.
What the client certificate proves, and what it does not
It proves possession of a private key corresponding to a certificate your CA issued. It says nothing by itself about what that identity is permitted to do. Authentication and authorization stay separate: after validation you still map the certificate to a principal, usually by reading the subject common name, an organizational unit, or a SAN entry, and then apply your own policy.
Two practical implications:
- A revoked certificate is only refused if you check revocation. Servers do not do this by default. You need a CRL that you keep current, or OCSP checking, or short lived certificates that expire faster than an incident response would take. Short lifetimes are the modern answer, and are why service mesh implementations issue certificates measured in hours.
- Certificate fields are not access control. Treat the mapped identity like any other principal and check permissions per request.
Setting it up
Issue client certificates from a dedicated internal CA, ideally separate from any CA that signs server certificates, so the two roles cannot be confused.
# One time: the client CA
openssl req -x509 -newkey rsa:4096 -nodes -days 3650 \
-keyout client-ca.key -out client-ca.crt \
-subj "/CN=Example Client CA"
# Per client: key plus signing request
openssl req -newkey rsa:2048 -nodes \
-keyout billing-svc.key -out billing-svc.csr \
-subj "/CN=billing-svc/OU=payments"
# Sign it
openssl x509 -req -in billing-svc.csr \
-CA client-ca.crt -CAkey client-ca.key -CAcreateserial \
-out billing-svc.crt -days 90
The CSR generator produces correctly formed requests if you would rather not assemble the subject by hand, and the SSL Decoder confirms what ended up in the issued certificate. If you need to check that a key and certificate belong together before deploying, the key matcher does exactly that.
On nginx, enabling mTLS is three directives:
server {
listen 443 ssl;
server_name api.internal.example.com;
ssl_certificate /etc/ssl/certs/api.crt;
ssl_certificate_key /etc/ssl/private/api.key;
ssl_client_certificate /etc/ssl/certs/client-ca.crt;
ssl_verify_client on;
ssl_verify_depth 2;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Verify $ssl_client_verify;
}
}
ssl_verify_client on rejects any connection without a valid client certificate. optional accepts the connection and records the outcome in $ssl_client_verify, which is useful during rollout: run in optional mode, log which clients are presenting certificates, and flip to on once every caller is covered. Note that if you pass the identity downstream in a header, as above, the backend must not be reachable except through the proxy, or that header can simply be forged. The general problem is described in what is a reverse proxy.
Calling it with curl:
curl --cert billing-svc.crt --key billing-svc.key https://api.internal.example.com/health
Client certificate authentication is not something a browser fetch or a hosted request tool can perform on your behalf, since the private key must stay on the client. The API Tester runs requests from your own browser, so it will reach an mTLS endpoint only if the certificate is installed in your operating system's store and you accept the browser's selection prompt. For service to service testing, curl or your language's HTTP client is the practical path.
mTLS or bearer tokens?
| mTLS | API key or bearer token | |
|---|---|---|
| Where it is checked | TLS handshake | Application layer |
| Credential in transit | Never sent, only proven | Sent with every request |
| Leaks in logs | No | Yes, routinely |
| Rotation | Reissue certificate | Reissue string |
| Browser and mobile friendly | Poor | Good |
| Third party developer friendly | Poor | Good |
The decisive difference is the second row. A bearer token is a shared secret transmitted on every request, so it leaks through proxy logs, error reports, and screenshots, and anyone holding it can replay it. A client certificate's private key never leaves the client; the handshake only proves possession. That is why sharing logs safely is a recurring problem with tokens and a non problem with mTLS.
The cost is distribution. Every client needs a key pair, a signing process, secure storage, and renewal. That is manageable for services you operate and unpleasant for external developers or end users, which is why the reasonable split is:
- Use mTLS for service to service traffic, internal APIs, partner integrations with a small fixed set of counterparties, and any environment where a service mesh already automates issuance and rotation.
- Use tokens for browsers, mobile apps, and public APIs, where OAuth and OpenID Connect exist precisely because certificate distribution to end users does not work.
They also combine: mTLS for the transport identity of the calling service, plus a token carrying the acting user. That is the standard shape in zero trust architectures, and each layer answers a genuinely different question.
Verifying and monitoring
Confirm the server actually requests a certificate:
openssl s_client -connect api.internal.example.com:443 </dev/null 2>&1 | grep -A5 "Acceptable client certificate"
Then treat client certificates like every other certificate you depend on: they expire, and a service whose certificate lapses fails at connect time with an error that rarely names the real cause. Track expiry across hosts with the bulk SSL checker and keep the alert threshold well ahead of the renewal work, as covered in monitor SSL certificate expiry.