Skip to content

Certificates Tls Mtls

Certificates, TLS, and Mutual TLS — A Complete Guide

Section titled “Certificates, TLS, and Mutual TLS — A Complete Guide”

A ground-up reference for how services prove their identity to each other using X.509 certificates, how TLS works at the handshake level, and how mTLS extends that to authenticate both sides of a connection.


  1. Why service-to-service authentication matters
  2. Authentication mechanism comparison
  3. Certificate fundamentals
  4. The TLS handshake — one-way and mutual
  5. How mTLS works end-to-end
  6. Implementing mTLS
  7. Common failure modes
  8. Glossary

When a browser talks to a website, a human authenticates — username, password, MFA. But when one backend service calls another, there is no human to type a password. The calling service still has to answer the receiver’s question:

“Who are you, and are you allowed to call me?”

That is service-to-service authentication. Sitting inside a trusted network is not sufficient on its own — a compromised host, misconfigured firewall, or malicious insider can otherwise call any internal service freely.

Two distinct questions are always in play. Keep them separate:

QuestionNameExample
“Who are you?”Authentication (authN)This caller is the order-service.
“What are you allowed to do?”Authorization (authZ)order-service may call CreateOrder but not DeleteUser.

Certificates answer authentication. Authorization is a separate layer on top — typically RBAC, JWT claims, or a policy engine.


Common ways a service proves its identity, roughly weakest to strongest:

MechanismHow it proves identityStrengthsWeaknesses
Network trust only“You reached me through the firewall/VPN/mesh”Zero app codeNo real identity — one breach = full access
API key / shared secretCaller sends a long secret string in a headerSimple to implementSecret can leak; same key for everyone; manual rotation
HMAC signed requestCaller signs the request body with a shared secretTamper-proof; secret never transmittedBoth sides share a secret; clock-skew handling needed
Bearer token / JWTShort-lived signed token from an auth serverScales; carries claims; short-livedToken theft = impersonation until expiry
Kerberos / NTLMOS proves the calling account via Active DirectoryNo secrets in the appWindows/AD-only; awkward across trust boundaries
Mutual TLS (client certificate)Caller presents an X.509 cert and proves it owns the private key during the TLS handshakeStrong crypto identity; happens at transport layer before app code runs; nothing secret is transmittedCert lifecycle management (issue, rotate, expire, revoke)

Before mTLS makes sense you need four concepts: key pairs, certificates, certificate authorities, and certificate stores.

3.1 The key pair (asymmetric cryptography)

Section titled “3.1 The key pair (asymmetric cryptography)”

A certificate is built on a public/private key pair. The fundamental property:

  • Anything signed with the private key can be verified with the public key.
  • Anything encrypted with the public key can only be decrypted with the private key.

The private key is a secret you never share. The public key you hand out freely. This lets you prove identity without transmitting a secret: sign something with your private key; the other side verifies with your public key.

An X.509 certificate is a structured file that bundles:

  • A public key
  • Identity fields — the Subject, whose Common Name (CN) is the human-readable identity (e.g. payment-service.internal.corp)
  • Validity dates (Not Before / Not After)
  • The Issuer — who vouches for it — and the issuer’s digital signature
  • A thumbprint — a hash that uniquely fingerprints the whole certificate

The certificate does not contain the private key. The private key is stored separately and protected. A .pfx/.p12 file bundles cert + private key together (password-protected, for installation). A .cer/.crt is the public cert only.

openssl x509 -text -noout -in cert.pem decodes any certificate:

Certificate:
Data:
Version: 3
Serial Number: 04:ff:b3:14:28:c2:91:2d
Signature Algorithm: sha256WithRSAEncryption
Issuer: C=US, O=Acme Corp Internal CA, CN=Acme-Internal-CA
Validity
Not Before: Jan 15 00:00:00 2024 GMT
Not After: Jan 15 23:59:59 2025 GMT
Subject: C=US, O=Acme Corp, CN=payment-service.internal.corp
Subject Public Key Info:
Public Key Algorithm: rsaEncryption
RSA Public-Key: (2048 bit)
Modulus: 00:b3:4a:28:f9:c1:7e:...
Exponent: 65537
X509v3 Extensions:
X509v3 Key Usage: Digital Signature, Key Encipherment
X509v3 Extended Key Usage: TLS Web Client Authentication
Signature Algorithm: sha256WithRSAEncryption
8f:2a:14:bc:77:... ← CA's digital signature over everything above

On disk, stored as Base64-encoded DER binary — PEM format:

-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIIBP+zFCjCkS0wDQYJKoZIhvcNAQELBQAwOTELMAkGA1UE
... (binary structure above, base64-encoded)
-----END CERTIFICATE-----

The private key is always a completely separate file and is never shared:

-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAs0oo+cEuPhM2... (leaking this lets anyone impersonate you)
-----END RSA PRIVATE KEY-----

plantuml

3.3 Certificate Authorities and the chain of trust

Section titled “3.3 Certificate Authorities and the chain of trust”

How do you trust a certificate you have never seen? Because a Certificate Authority (CA) you already trust has signed it. Your OS ships with a set of trusted Root CA certificates. A cert may be signed by an intermediate CA, which is itself signed by the root — forming a chain:

graphviz

When a cert is presented, the receiver walks the chain up to a trusted root. If the chain is valid, not expired, and not revoked, the cert is trusted.

When the client receives the server’s certificate it tries to walk the chain up to a root it already trusts. If that root CA is not in the trust store, the walk terminates without reaching a trusted anchor and the handshake is aborted.

CLIENT SERVER
│ ClientHello │
│ ─────────────────────────────────────────────►│
│ │
│ Certificate (signed by Internal CA) │
│ ◄───────────────────────────────────────────── │
│ │
│ Walk chain... │
│ Internal CA root → NOT in trust store │
│ │
│ TLS alert 48: unknown_ca │
│ Handshake aborted ✗ │
│ │

Common causes:

SituationWhy the CA is missing
Internal / private PKICompany runs its own CA; root cert not distributed to all clients
Self-signed certificateThe server signed its own cert — it is its own CA, trusted by nobody else
Corporate MITM proxyProxy intercepts TLS with its own CA; some clients haven’t been given that CA cert
New public CAClient OS trust store is outdated; the CA was added after the last OS update
Container / CI environmentMinimal base image ships with no or minimal CA bundle

Fix 1 — install the CA cert into the system trust store (the right long-term fix)

Terminal window
# Debian / Ubuntu
sudo cp internal-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
# RHEL / CentOS / Fedora
sudo cp internal-ca.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust
# macOS
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain internal-ca.crt
# Windows (PowerShell, run as Administrator)
Import-Certificate -FilePath "internal-ca.crt" `
-CertStoreLocation Cert:\LocalMachine\Root

Fix 2 — pass the CA cert explicitly in code (no changes to the OS trust store)

Useful in containers, CI, or when you cannot touch system-wide configuration.

# Python — requests
requests.get("https://payment-service.internal.corp", verify="internal-ca.crt")
// Go
caCert, _ := os.ReadFile("internal-ca.crt")
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caCert)
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool},
},
}
// .NET — inject the CA into a custom chain policy
var handler = new HttpClientHandler();
handler.ServerCertificateCustomValidationCallback = (msg, cert, chain, errors) => {
chain.ChainPolicy.ExtraStore.Add(new X509Certificate2("internal-ca.crt"));
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
return chain.Build(cert);
};
var client = new HttpClient(handler);
Terminal window
# curl
curl --cacert internal-ca.crt https://payment-service.internal.corp

Fix 3 — certificate pinning (skip the CA model entirely)

Instead of trusting a CA, the client stores the expected certificate fingerprint (or public key hash) and checks the presented cert matches it directly. No CA needed at all.

Terminal window
# Get the fingerprint of the server cert
openssl x509 -noout -fingerprint -sha256 -in server.crt
# SHA256 Fingerprint=AB:CD:12:...
# curl — pin to the specific cert
curl --pinnedpubkey "sha256//base64encodedHash=" https://payment-service.internal.corp
PlatformLocationNotes
WindowsLocalMachine\My (Personal store)Machine-wide; .pfx imports cert + private key together
Linux / macOS/etc/ssl/certs/, /etc/pki/tls/, or application-managedPEM files; private key is a separate file with 600 permissions
KubernetesSecret of type kubernetes.io/tlsProjected into the pod as a volume; key never exposed to the image
HSMHardware Security ModulePrivate key never exported; crypto operations run inside the device

3.5 How a CA signs a certificate — the CSR flow

Section titled “3.5 How a CA signs a certificate — the CSR flow”

The signing process is a concrete cryptographic operation. It starts with you generating a key pair and creating a CSR (Certificate Signing Request) — a file that says “I am CN=X, here is my public key, and here is a signature proving I own the matching private key.” Only the CSR goes to the CA; the private key never leaves your host.

plantuml


TLS (the “S” in HTTPS) does two jobs: encrypts the connection and authenticates the parties. There are two flavors.

4.1 One-way TLS — only the server authenticates

Section titled “4.1 One-way TLS — only the server authenticates”

This is standard HTTPS. Only the server presents a certificate; the client stays anonymous.

CLIENT SERVER
│ 1. ClientHello — "let's negotiate TLS" │
│ ────────────────────────────────────────────────►│
│ │
│ 2. ServerHello + Certificate (public key) │
│ ◄──────────────────────────────────────────────── │
│ │
│ 3. Client validates cert chain + freshness │
│ (CA trusted? not expired? CN matches host?) │
│ │
│ 4. ServerKeyExchange + signature │
│ (proves server holds the private key) │
│ ◄──────────────────────────────────────────────── │
│ │
│ 5. Both derive shared session key via ECDHE. │
│ Encrypted channel is live. │
│ ◄─────────────────────────────────────────────── ►│

The client learns “I really am talking to payment-service.” But the server has no idea who the client is — that is why web apps then ask you to log in.

CertificateVerify — how possession is proved

Section titled “CertificateVerify — how possession is proved”

A certificate is a public file — anyone can copy it. Simply sending the cert during the handshake proves nothing. The proof comes from a separate ServerKeyExchange message:

After sending its certificate, the server sends:
signature = RSA_sign(
SHA256(client_random + server_random + ephemeral_DH_public_key),
server_private_key ← only the genuine server can produce this
)
The client verifies the signature using the public key in the presented cert.
✓ Passes → server genuinely holds the private key
✗ Fails → abort; someone is impersonating the server

Because the signature covers freshly-generated random values unique to this session, it cannot be replayed from any prior connection.

ECDHE — how both sides derive the same session key

Section titled “ECDHE — how both sides derive the same session key”

The core trick: multiplying a number by a curve point is a one-way operation — fast forward, infeasible to reverse. That single property is what makes the exchange safe over a public wire.

svgbob

Why both sides get the same result: scalar multiplication is associative, so a×(b×G) = b×(a×G) = (a×b)×G. Each side used their own private number with the other side’s public point and arrived at identical output.

plantuml

4.2 Mutual TLS (mTLS) — both sides authenticate

Section titled “4.2 Mutual TLS (mTLS) — both sides authenticate”

mTLS adds one message: the server sends a CertificateRequest, asking the client to also prove its identity. Now both identities are established at the transport layer, before any application code runs.

CLIENT (order-service) SERVER (payment-service)
│ 1. ClientHello │
│ ────────────────────────────────────────────────►│
│ │
│ 2. ServerHello + Certificate │
│ + CertificateRequest ← mTLS-only message │
│ ◄──────────────────────────────────────────────── │
│ │
│ 3. Client validates server cert. │
│ Client sends ITS cert (public key) │
│ + CertificateVerify (signature with │
│ its own private key — the proof) │
│ ────────────────────────────────────────────────►│
│ │
│ 4. Server validates client cert chain │
│ AND verifies the CertificateVerify │
│ signature → client owns the private key. │
│ Server checks: is this CN on the allow-list? │
│ │
│ 5. Both authenticated. Encrypted channel up. │
│ ◄──────────────────────────────────────────────►│

plantuml

What mTLS actually verifies — two independent checks:

CheckQuestionMechanism
Chain validationIs this cert genuine?Walk the chain to a trusted root CA
CertificateVerifyDoes the presenter own the cert?Verify the signature — only possible with the private key

4.3 Session keys — why they exist and how they work

Section titled “4.3 Session keys — why they exist and how they work”

AES is symmetric encryption — the same key both encrypts and decrypts. That is the opposite of RSA/ECDHE (asymmetric), where a public key encrypts and a different private key decrypts. Symmetric is much faster (think nanoseconds per record vs milliseconds for asymmetric), which is why all actual TLS data is encrypted with AES, not with the certificate’s RSA key.

The catch: both sides must hold the identical key before they can talk. You cannot send the key over the wire — anyone listening would grab it. That is exactly the problem ECDHE solves: it lets both sides independently arrive at the same secret without ever transmitting it.

The single-sentence reason session keys exist: ECDHE is powerful but slow — it runs once to agree on a shared secret. That secret is fed into a fast “key factory” (PRF) that produces short-lived AES keys used for all actual data. When the session ends, every key is thrown away.

svgbob

How both sides independently arrive at the same keys

The PRF (key factory) is deterministic — same inputs always produce the same outputs. Both sides feed it the exact same three things:

InputWhere it comes fromBoth sides have it?
pre_master_secretECDHE — each side computed the same value independently (a×B = b×A)✓ yes — that was ECDHE’s whole job
client_randomClient generated it, sent openly in ClientHello✓ yes — exchanged in plaintext
server_randomServer generated it, sent openly in ServerHello✓ yes — exchanged in plaintext
Client computes: Server computes:
PRF( Kc_priv×Ks_pub, PRF( Ks_priv×Kc_pub,
client_random, client_random,
server_random ) server_random )
│ │
▼ ▼
client_write_key = X ◄──── same ────► client_write_key = X
server_write_key = Y ◄──── same ────► server_write_key = Y

Neither key was transmitted. Both sides computed them independently from the same ingredients.

How a record travels over the wire:

CLIENT SERVER
"GET /orders"
│ encrypt( "GET /orders", client_write_key )
[ ciphertext | auth_tag ] ──────────────────────────────► decrypt( ciphertext,
client_write_key )
verify auth_tag
▼ if tag ✓ → "GET /orders"
▼ if tag ✗ → drop (tampered)

The auth_tag is a fingerprint over the ciphertext. One flipped bit in transit → tag check fails → record silently dropped. No extra signature step needed.

  • No shared secret transmitted — identity proof uses signing, not secret comparison
  • Transport-layer enforcement — rejected before application code runs; not bypassable by the app
  • Mutual — both sides are authenticated in the same handshake, no second round-trip
  • Forward secrecy — ephemeral ECDHE keys mean past sessions are safe if a cert is later compromised
  • Phishing-resistant — the proof is cryptographically bound to this specific handshake session

For a call from order-service → payment-service, the complete picture:

On the client (order-service) host:

  1. The client certificate (order-service.internal.corp) is installed in the cert store with its private key.
  2. The process identity the service runs as has read access to the private key.
  3. When making the HTTPS call, the TLS stack attaches the certificate and uses the private key to produce the CertificateVerify signature.

On the server (payment-service) host: 4. The TLS server is configured to request a client cert (ssl_verify_client on in nginx, ClientCertificateMode.RequireCertificate in ASP.NET, AccessSSLRequireCert in IIS). 5. The server validates the presented cert’s chain (CA trusted? not expired?). 6. The server checks an allow-list: is this cert’s Subject CN mapped to an allowed caller? 7. If yes, the request is allowed through to the application. If no → HTTP 403 (cert not authorized), before any controller code runs. 8. The application may add a further authorization check (e.g. RBAC, JWT claims) on top.


Terminal window
# 1. Generate the private key — stays on this host only
openssl genrsa -out client.key 2048
# 2. Create a CSR
openssl req -new -key client.key -out client.csr \
-subj "/CN=order-service.internal.corp/O=Acme Corp"
# 3. Have your CA sign it (internal CA example using openssl)
openssl x509 -req -in client.csr \
-CA ca.crt -CAkey ca.key -CAcreateserial \
-out client.crt -days 365 -sha256
# 4. Bundle as PKCS#12 (for stores that need it, e.g. Windows)
openssl pkcs12 -export -out client.pfx -inkey client.key -in client.crt

6.2 Client side — attaching the cert to outgoing calls

Section titled “6.2 Client side — attaching the cert to outgoing calls”
import requests
response = requests.get(
"https://payment-service.internal.corp/api/charge",
cert=("client.crt", "client.key"), # client cert + private key
verify="ca.crt", # validate server cert against internal CA
)
cert, err := tls.LoadX509KeyPair("client.crt", "client.key")
if err != nil {
log.Fatal(err)
}
caCert, _ := os.ReadFile("ca.crt")
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caCertPool,
}
client := &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsConfig},
}
var cert = new X509Certificate2("client.pfx", "password");
var handler = new HttpClientHandler();
handler.ClientCertificates.Add(cert);
// validate server cert against internal CA
handler.ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; // dev only
// In production: load and pin your internal CA cert instead
var client = new HttpClient(handler);
const https = require("https");
const fs = require("fs");
const options = {
cert: fs.readFileSync("client.crt"),
key: fs.readFileSync("client.key"),
ca: fs.readFileSync("ca.crt"), // trust internal CA
};
const req = https.request("https://payment-service.internal.corp/", options, (res) => {
// handle response
});

6.3 Server side — requiring and validating the client cert

Section titled “6.3 Server side — requiring and validating the client cert”
server {
listen 443 ssl;
ssl_certificate /etc/ssl/server.crt;
ssl_certificate_key /etc/ssl/server.key;
ssl_client_certificate /etc/ssl/internal-ca.crt; # CA to validate client certs against
ssl_verify_client on; # require a valid client cert
ssl_verify_depth 2; # walk the chain up to 2 levels
location / {
# $ssl_client_s_dn contains the client cert Subject DN
# Use it to enforce an allow-list at the application level if needed
proxy_set_header X-Client-Cert-Subject $ssl_client_s_dn;
proxy_pass http://backend;
}
}
builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options =>
{
options.AllowedCertificateTypes = CertificateTypes.All;
options.RevocationMode = X509RevocationMode.NoCheck; // or Online for CRL
options.Events = new CertificateAuthenticationEvents
{
OnCertificateValidated = context =>
{
// Enforce allow-list by CN
var allowedCNs = new[] { "order-service.internal.corp", "inventory-service.internal.corp" };
var cn = context.ClientCertificate.GetNameInfo(X509NameType.SimpleName, false);
if (!allowedCNs.Contains(cn))
{
context.Fail("Client certificate not on allow-list");
return Task.CompletedTask;
}
context.Success();
return Task.CompletedTask;
}
};
});
// Require HTTPS and client cert
builder.WebHost.ConfigureKestrel(options =>
{
options.ConfigureHttpsDefaults(https =>
{
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
});
});

Rotation procedure:

1. Generate a new key pair + CSR (new private key must be generated, not reused)
2. Submit CSR to your CA → receive new signed certificate
3. Install the new cert alongside the old one (both active for a brief window)
4. Rolling-deploy services to use the new cert
5. Remove the old cert once all services have been updated
6. Verify no traffic references the old cert (check metrics / access logs)

A zero-downtime rotation requires:

  • The server’s allow-list accepts both the old and new client CN during the overlap window
  • Or the CN does not change (same CN, new key pair + re-issued cert) so no allow-list changes are needed

SymptomLikely causeHow to diagnose
HTTP 403 / TLS alert “certificate required”Server requires a client cert but none was attachedCheck client-side attach logic; confirm cert is in the cert store with its private key
HTTP 403 / “certificate rejected”Client cert’s CN is not in the server’s allow-listInspect the server’s cert mapping configuration; confirm the CN matches exactly
“Could not establish trust relationship”Server cert not trusted by the client (chain or root missing)Check client’s trusted-root store; add internal CA root if needed
Cert loads but handshake fails silentlyProcess lacks read access to the private keyCheck ACLs on the private key file; use openssl verify to confirm the key matches the cert
Works in dev, fails in staging/prodmTLS disabled in dev by environment flag but enabled in stagingCheck for environment guards around cert attachment; confirm cert is deployed to all environments
Cert not found in storeCN mismatch, wrong store location, or cert not deployedOn the host, list certs matching the expected CN; confirm LocalMachine\My vs CurrentUser\My
Cert found but PrivateKey is nullCert installed without the private key (public cert only)Re-import the .pfx bundle including the private key
“Certificate has expired”Not After date passedRenew cert; automate expiry alerts (alert at 30 days, 14 days, 7 days)
Revoked cert still acceptedCRL/OCSP checking not configured on the serverEnable X509RevocationMode.Online or configure OCSP stapling

Quick diagnostic commands:

Terminal window
# Inspect a certificate's fields
openssl x509 -text -noout -in cert.pem
# Verify the private key matches the certificate
openssl rsa -check -in private.key # key is valid
openssl x509 -noout -modulus -in cert.pem | openssl md5 # cert modulus hash
openssl rsa -noout -modulus -in private.key | openssl md5 # key modulus hash
# → hashes must match
# Walk the chain and validate against a CA
openssl verify -CAfile ca.crt cert.pem
# Test mTLS end-to-end
openssl s_client -connect payment-service.internal.corp:443 \
-cert client.crt -key client.key \
-CAfile ca.crt
# Check a remote server's cert
echo | openssl s_client -connect payment-service.internal.corp:443 2>/dev/null \
| openssl x509 -noout -dates -subject -issuer

TermMeaning
Authentication (authN)Proving who you are
Authorization (authZ)Deciding what you may do
X.509 certificateStandard file format bundling a public key + identity + CA signature
Public/private key pairAsymmetric keys: sign with private; verify with public; encrypt with public; decrypt with private
CA (Certificate Authority)An entity whose signature makes a cert trustworthy
Root CAA CA whose cert is trusted directly by the OS/browser — the anchor of the chain of trust
Intermediate CAA CA signed by the root; issues leaf/service certs without exposing the root key
Chain of trustLeaf cert → intermediate CA → root CA that the OS trusts
Subject / CN (Common Name)The certificate’s identity, e.g. payment-service.internal.corp
ThumbprintA hash uniquely fingerprinting a specific certificate file
PEMBase64-encoded certificate format — -----BEGIN CERTIFICATE-----
PKCS#12 / PFXBundle format containing cert + private key, password-protected
CSR (Certificate Signing Request)File sent to a CA to request issuance of a signed certificate; contains public key and identity; does not contain private key
TLSTransport Layer Security — encrypted + authenticated connection (the S in HTTPS)
mTLS (mutual TLS)TLS where both client and server present certificates
Client certificateThe cert a calling service presents to prove its identity
CertificateVerifyTLS handshake message containing the client’s signature proof that it holds the private key
ECDHEElliptic Curve Diffie-Hellman Ephemeral — key exchange that provides forward secrecy
Forward secrecyProperty ensuring past sessions cannot be decrypted even if long-term keys are later compromised
CRLCertificate Revocation List — a CA-published list of revoked cert serial numbers
OCSPOnline Certificate Status Protocol — real-time revocation check against the CA
Allow-listServer-side list of client cert CNs that are permitted to connect
ACL (private key)Access Control List restricting which OS accounts may read a certificate’s private key