mTLS 101
First things first:
What is TLS?
TLS (Transport Layer Security) is performed at the Transport Layer, the layer preceding the Application Layer in the TCP/IP model. TLS verification is concerned with ensuring the authenticity of the server.
For example, when a client initiates an HTTPS request to the server, it requests a certificate from the server. The server sends the certificate, along with a digital signature; the client validates it and then proceeds with the request.
What is a certificate?
- A certificate is a public document, usually issued by a third party, called the Certificate Authority (CA). It can be shared with anyone.
- It contains information like the issue date, expiry date, the certificate issuer identity (CA), subject fields and cryptographic info like the public key, etc.
- The certificate ownership is attested by the ownership of the private key that was used to sign that certificate. It is not shared with anyone, not even the CA.
What is certificate validation?
- When the server sends the certificate to the client, it also sends a digital signature, generated using the private key that was used to sign the certificate.
- The client verifies the digital signature using the public key from the certificate, and also ensures that the certificate is issued by a CA trusted by the client.
What is mTLS?
Normal TLS is one-way verification: the client validates the server's authenticity. mTLS (two-way TLS) goes a step further by also requiring the client to prove its authenticity to the server.
For example:
- When a client initiates an HTTPS request to the server, it requests the server's certificate.
- The server sends the certificate, along with a digital signature, and also requests the client certificate.
- The client sends the certificate, along with a digital signature, and also verifies the server certificate.
- The server also verifies the client certificate, and the secure connection is established.
Certificate Files:
Let’s assume there is a server and a client; both get their certificates issued by two different CAs (Certificate Authorities), although it’s perfectly valid if they share the same CA.
- Both CAs have their own private keys & certificates:
- ca-server.key
- ca-server.crt
- ca-client.key
- ca-client.crt
While the keys are always self-generated, these certificates may be issued by another third party or be self-signed certificates (as in our example).
openssl genrsa -out certs/ca-server.key 4096
openssl genrsa -out certs/ca-client.key 4096
openssl req -x509 \
-new \
-key certs/ca-server.key \
-sha256 \
-out certs/ca-server.crt \
-subj "/C=IN/ST=Maharashtra/L=Mumbai/O=CA Server ORG/CN=Demo Server CA"
openssl req -x509 \
-new \
-key certs/ca-server.key \
-sha256 \
-out certs/ca-client.crt \
-subj "/C=IN/ST=Maharashtra/L=Mumbai/O=CA Client ORG/CN=Demo Client CA"2. The client and server generate a CSR (Certificate Signing Request) file using their private keys and send it to their respective CAs to obtain their public certificates. Note that they don’t share their private keys to CAs.
openssl genrsa -out certs/server.key 2048
openssl genrsa -out certs/client.key 2048
openssl req -new \
-key certs/server.key \
-out certs/server.csr \
-subj "/C=IN/ST=Maharashtra/L=Mumbai/O=Server ORG/CN=Demo Server"
openssl req -new \
-key certs/client.key \
-out certs/client.csr \
-subj "/C=IN/ST=Karnataka/L=Bengaluru/O=Client ORG/CN=Demo Client"3. The CAs generate the server and client certificates using the CSR files. The server-ext.cnf file contains additional certificate information such as the purpose of the certificate and the DNS & IP of the server.
openssl x509 -req \
-in certs/server.csr \
-CA certs/ca.crt \
-CAkey certs/ca.key \
-out certs/server.crt \
-days 365 \
-sha256 \
-extfile certs/server-ext.cnf
openssl x509 -req \
-in certs/client.csr \
-CA certs/ca.crt \
-CAkey certs/ca.key \
-CAcreateserial \
-out certs/client.crt \
-days 365 \
-sha2564. The CAs also pass their certificates to their respective parties: server (ca-server.crt) & client (ca-client.crt). Then, the server passes its CA certificate to the client, and the client passes its CA certificate to the server.
This is very important. As stated in the certificate validation step, the party — the client or the server — also needs to trust the issuer of the certificate.
Get Tushar’s stories in your inbox
Join Medium for free to get updates from this writer.
5. We can also verify the server.crt & client.crt using their CA certificate files.
openssl verify -CAfile certs/ca-server.crt certs/server.crt
openssl verify -CAfile certs/ca-client.crt certs/client.crtHuh, but show me the code!
Server:
The following code snippet shows the server-side TLS configuration. It accesses the server certificate & the private key, so it can pass the certificate and the digital signature (not the private key itself) to the client during the TLS verification.
Notice it also sets the certificate of the client’s CA in “clientCAs”. This is done to specify that the server will trust the client certificates which are issued only by this CA.
func getTlsConfig() (*tls.Config, error) {
basePath, err := os.Getwd()
if err != nil {
return nil, err
}
certsPath := filepath.Join(basePath, "certs")
serverCertPath := filepath.Join(certsPath, "server.crt")
serverKeyPath := filepath.Join(certsPath, "server.key")
tlsCertificate, err := tls.LoadX509KeyPair(serverCertPath, serverKeyPath)
if err != nil {
return nil, err
}
caCertPath := filepath.Join(certsPath, "ca-client.crt")
caCert, err := os.ReadFile(caCertPath)
if err != nil {
return nil, err
}
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM(caCert) {
return nil, errors.New("failed to append ca cert")
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{tlsCertificate},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: caPool,
}
return tlsConfig, nil
}Then, we can simply start the web server on port 8080 with this TLS config.
func main() {
tlsConfig, err := getTlsConfig()
if err != nil {
log.Fatalf("error while getting tls config: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("PING PONG"))
})
server := &http.Server{
Addr: ":8080",
TLSConfig: tlsConfig,
Handler: mux,
}
if err := server.ListenAndServeTLS("", ""); err != nil {
log.Fatalf("error while starting server: %v", err)
}
}Client:
The following code snippet shows the client-side TLS configuration. It accesses the client certificate & the private key, so it can pass the certificate and the digital signature (not the private key itself) to the server during the TLS verification.
Notice that it also sets the certificate of the server’s CA in “RootCAs”. This is done to specify that the client will trust the server certificates which are issued only by this CA.
func getTlsConfig() (*tls.Config, error) {
basePath, err := os.Getwd()
if err != nil {
return nil, err
}
certsPath := filepath.Join(basePath, "certs")
clientCertPath := filepath.Join(certsPath, "client.crt")
clientKeyPath := filepath.Join(certsPath, "client.key")
tlsCertificate, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
if err != nil {
return nil, err
}
caCertPath := filepath.Join(certsPath, "ca-server.crt")
caCert, err := os.ReadFile(caCertPath)
if err != nil {
return nil, err
}
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM(caCert) {
return nil, errors.New("error to load certificate")
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{tlsCertificate},
RootCAs: caPool,
ServerName: "localhost",
}
return tlsConfig, nil
}The server name “localhost” is also required, so the client can trust that it is connecting to the right server. The server DNS was configured in the server certificate by the server-ext.cnf file.
Now, we can build the HTTP client object and hit the server to perform a test request. Note that we use HTTPS in the request URL, not HTTP, because TLS verification occurs only in HTTPS requests.
func getHttpClient() (*http.Client, error) {
tlsConfig, err := getTlsConfig()
if err != nil {
return nil, err
}
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
}
return client, nil
}
func main() {
client, err := getHttpClient()
if err != nil {
log.Fatalf("error while get http client: %v", err)
}
ctx := context.Background()
url := "https://localhost:8080"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
log.Fatalf("error while creating http req: %v", err)
}
resp, err := client.Do(req)
if err != nil {
log.Fatalf("error while doing http req: %v", err)
}
log.Println("status", resp.Status)
}Intermediate CA says ‘Hi’
In some cases, the certificate of the server or the client could be signed by an Intermediate CA, and the intermediate CA’s certificate by a root CA. And the “other party” might trust only the root CA, not the intermediate CA. So, the “other party” will need to build the verification chain up to the root CA.
Let’s say the client’s certificate was issued by an intermediate CA. So, the client will create a certificate chain — by appending the intermediate CA’s certificate to its certificate — and send it to the server for verification. Then, the server can complete the verification by building the certificate chain from the client.crt up to the root CA like this:
client.crt → intermediate-ca.crt → root-ca.crt
There will be the following files:
- ca-server.key (private key of server’s CA)
- ca-server.crt (server’s CA certificate)
- server.key (server’s private key)
- server-ext.cnf (server’s config file)
- server.csr (server’s certificate signing request, sent to CA)
- server.crt (server’s certificate)
- ca-client-root.key (private key of client’s root CA)
- ca-client-root.crt (certificate of client’s root CA)
- ca-client-intermediate.key (private key of client’s intermediate CA)
- ca-client-intermediate.cnf (config file of client’s intermediate CA)
- ca-client-intermediate.csr (CSR file of client’s intermediate CA, sent to client’s root CA)
- ca-client-intermediate.crt (certificate of client’s intermediate CA)
- client.key (client’s private key)
- client.csr (client’s certificate signing request, sent to client’s intermediate CA)
- client.crt (client’s certificate)
- client-chain.crt (client.crt + ca-client-intermediate.crt)
The TLS config code will remain pretty much the same, except for one major change: the client loads the client-chain.crt instead of client.crt.
Check out the GitHub repo here.








![Your WebSocket limit is not where you think [Part 2]](https://webproxy.poorya-velaei-d67.workers.dev/https://miro.medium.com/v2/resize:fit:1358/format:webp/1*3VB7wT41Kmem8PkxGgK3LQ.png)