Skip to content

Part 3 - Client Side Authentication

In the previous part, we added TLS to our connection. That already gives us three important security properties:

  • Encryption (confidentiality): The communication is no longer sent in clear text, so passive attackers can't read it.
  • Integrity (tamper detection): Data cannot be modified in transit without the receiver noticing.
  • Server authentication: The client can verify that it is talking to the expected server (and not an impostor).

So far, the client can confirm the server's identity - but the server cannot (yet) confirm the client's identity.

Now assume you build and sell an embedded device that communicates with a backend service. The device might be resource-constrained, while much of the value lives in the server infrastructure (data, algorithms, paid features, APIs, etc.). In that case, you typically want to ensure that only your own devices can use that infrastructure — not clones or PCs emulating your software.

To achieve this, we add client-side authentication, using mutual TLS (mTLS).

Certified client and server

How Client Authentication Works

Client authentication uses the same building blocks as server authentication:

  • A certificate signed by a trusted CA
  • A private key corresponding to that certificate

With server authentication, the server presents a CA-signed certificate to the client.

With mutual TLS, the client also presents a CA-signed certificate to the server.

Depending on your use case, the server and client certificates can be signed by:

  • the same CA, or
  • different CAs (common in production setups)

For simplicity, we reuse the CA created in Part 2.

ℹ️ In a real product, you will likely use a dedicated device CA and a more structured provisioning process.

One Certificate or Many?

On the server side, the model is straightforward: there is typically one server endpoint (or a small set), so you provision a corresponding set of server certificates.

On the client side, you usually have many devices, so you need to decide what your identity model should look like:

  • Should all devices share the same client certificate?
  • Should devices share a certificate per production batch?
  • Should product variants use different certificates?
  • Should every device have a unique certificate?

In this example, we create one client certificate. You can either deploy it to multiple devices for testing, or repeat the process to generate per-device certificates. We'll revisit deployment strategies in more detail in Part 4

Creating the Client Certificate

1. Generate the client private key

Similar to the server setup, we'll use an EC NIST P-256 key:

$ openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out client.key

2. Create a minimal Certificate Signing Request (CSR)

We create a CSR with a simple Common Name (CN). For development, this is sufficient. For public CAs (or stricter enterprise policies), you may need additional subject fields and extensions - consult your CA requirements.

$ openssl req -new -key client.key -out client.csr -subj "/CN=trusted-client"

3. Sign the client certificate with the CA

For our test setup we will reuse We reuse the CA created in Part 2.

In a final product you will send your CSR to the corporate or public CA instead.

For our test setup you will need:

  • client.csr - the CSR
  • ca.crt - the CA certificate
  • ca.key - the CA private key
$ openssl x509 -req \
  -in client.csr \
  -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out client.crt -days 365 -sha256 \
  -extfile - < /dev/stdin << 'EOF'
basicConstraints=CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=clientAuth
EOF

# Here starts output from the command
Certificate request self-signature ok
subject=CN=trusted-client

Code Changes

Client: Presenting a Certificate

In client.py, we load the client certificate and private key into the TLS context:

SERVER_CA = "server_ca.pem" # CA that signed server cert
CLIENT_CERT = "client.crt"
CLIENT_KEY = "client.key"

def main():
[..]

  context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
  context.load_verify_locations(cafile=SERVER_CA)

  # Present client certificate
  context.load_cert_chain(
    certfile=CLIENT_CERT,
    keyfile=CLIENT_KEY
  )

Everything else remains unchanged.

Server: Requiring Client Certificates

On the server side, we configure TLS to require a client certificate and verify it against a CA:

SERVER_CERT = "server.crt"
SERVER_KEY = "server.key"
CLIENT_CA = "client_ca.pem" # CA that signed client certs

def main():
  context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)

  context.load_cert_chain(
    certfile=SERVER_CERT,
    keyfile=SERVER_KEY
  )


  # Require client certificates
  context.verify_mode = ssl.CERT_REQUIRED
  context.load_verify_locations(cafile=CLIENT_CA)

Running the Code

Server:

$ ./server.py
TLS server listening on 0.0.0.0:5000
[+] TLS connection from ('192.168.178.2', 45336)
    Cipher: ('TLS_AES_256_GCM_SHA384', 'TLSv1.3', 256)
[('192.168.178.2', 45336)] Received: GET_SECRET
[-] Client disconnected: ('192.168.178.2', 45336)

Client:

$ ./client.py 192.168.178.2
Sending: GET_SECRET
Received: THIS_IS_THE_SECRET

What happens without a client certificate?

If you use the client from Part 2 (no certificate configured), the handshake fails:

Server:

ssl.SSLError: [SSL: PEER_DID_NOT_RETURN_A_CERTIFICATE] peer did not return a certificate

Client:

ssl.SSLError: [SSL: TLSV13_ALERT_CERTIFICATE_REQUIRED] tlsv13 alert certificate required

What happens with an untrusted client certificate?

If the client presents a certificate not signed by the configured CA:

Server:

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate in certificate chain

Client:

ssl.SSLError: [SSL: TLSV1_ALERT_UNKNOWN_CA] tlsv1 alert unknown ca

Server: Inspecting Client Identity

For server certificates, we typically validate that the certificate matches the server's hostname/IP. For client certificates, that kind of "location check" doesn't make sense: clients move networks, may sit behind NAT and often don't have stable public identities.

Of course if you have used dedicated device CAs during provisioning then you can already use the different CAs to identify clients.

Furthermore, we can inspect identity fields inside the client certificate - in this demo we used a very simple CN=trusted-client.

On the server, we can retrieve and inspect the peer certificate:

def handle_client(conn: ssl.SSLSocket, addr):
    client_cert = conn.getpeercert()
    print(f"[+] TLS connection from {addr}")
    print(f"    Cipher: {conn.cipher()}")
    print(f"    Client cert subject: {client_cert.get('subject')}")

    # Example authorization decision
    cn = None
    for item in client_cert.get("subject", []):
        if item[0][0] == "commonName":
            cn = item[0][1]

    if cn != "trusted-client":
        print("[-] Unauthorized client")
        conn.close()
        return

    with conn:
        while True:
            data = conn.recv(1024)
            if not data:
                break

            command = data.decode("utf-8")
            print(f"[{addr}] Received: {command.strip()}")

            response = handle_command(command)
            conn.sendall((response + "\n").encode("utf-8"))

    print(f"[-] Client disconnected: {addr}")

Example server output:

$ ./server.py
TLS server listening on 0.0.0.0:5000
[+] TLS connection from ('192.168.178.2', 47766)
 Cipher: ('TLS_AES_256_GCM_SHA384', 'TLSv1.3', 256)
 Client cert subject: ((('commonName', 'trusted-client'),),)
[('192.168.178.2', 47766)] Received: GET_SECRET
[-] Client disconnected: ('192.168.178.2', 47766)
This kind of inspection is a starting point. In real deployments, authorization is often based on richer certificate fields, certificate chains, or device-specific identifiers.

Conclusion

At this point, we have a functional client/server setup with mutual TLS:

  • Part 1: Plain TCP, clear-text traffic.
  • Part 2: TLS encryption + integrity + server authentication.
  • Part 3: Mutual TLS (mTLS): the server can authenticate the client.

This looks secure - as long as the sensitive material remains protected.

On the client, the key files are:

  • server_ca.pem - Used to validate the server certificate. This contains no secret and is often distributable.
  • client.crt - The client certificate. It is sent during the handshake and is considered public.
  • client.key - The client private key. This is highly sensitive. If an attacker obtains it, they can impersonate your device - exactly what client authentication is meant to prevent.

In the next part we will look at how a TPM can protect client.key.