How do you debug mTLS certificate failures between two microservices, and why is clock skew one of the most overlooked root causes?
Quick Answer
Work through the chain systematically: check certificate expiry, verify the CA chain, confirm the Subject Alternative Names (SANs) match the hostname being called, and test the raw handshake with openssl s_client before touching application code. Clock skew is easy to overlook because certificates aren't just checked for expiry — they also have a 'not valid before' timestamp, so a node whose clock is even a few minutes fast can reject a certificate that every other node in the fleet accepts as perfectly valid.
Detailed Answer
Debugging a failed mTLS handshake is like a border checkpoint rejecting a passport — the traveler insists it's valid, and it might genuinely be valid, but the rejection could be coming from several completely different places: the passport itself expired, the issuing country isn't recognized by this particular checkpoint, the name on the passport doesn't match the name on the travel documents, or — the one nobody thinks to check first — the checkpoint's own clock is wrong and it thinks today is a date before the passport was even issued. Each of these requires a completely different fix, and guessing instead of checking each one in order wastes far more time than working through them systematically.
This systematic approach matters because mTLS failures produce famously unhelpful error messages — 'certificate verify failed' or 'handshake failure' tells you almost nothing about which of five distinct failure modes actually occurred, so the debugging process itself has to supply the missing specificity that the error message doesn't give you. Each layer of the certificate chain and each validity check exists for a genuine security reason (preventing expired credentials, preventing impersonation, preventing a compromised or unauthorized CA from vouching for identities it shouldn't), which is exactly why TLS libraries fail closed and unhelpfully rather than telling you precisely which check failed — revealing that level of detail to an unauthenticated peer could itself leak information useful to an attacker.
Step by step: first, check expiry on both the leaf certificate and every certificate in its chain up to the root — openssl x509 -noout -dates on each file, since a chain is only as valid as its weakest link and an expired *intermediate* CA certificate breaks every leaf certificate it ever signed, not just one service. Second, verify the chain resolves correctly with openssl verify -CAfile ca-bundle.pem cert.pem — this catches cases where a service's TLS config is pointed at the wrong or an outdated CA bundle, a common failure after a CA rotation where some services get the new bundle deployed and others don't. Third, confirm the SAN (Subject Alternative Name, the field in a certificate that lists which hostnames it's valid for) actually includes the hostname being dialed — internal service renames or a certificate issued for the wrong internal DNS name is a frequent, easy-to-miss cause. Fourth, actually perform the handshake end-to-end with openssl s_client -connect service:port -cert client.crt -key client.key -CAfile ca.pem, which surfaces the exact TLS alert code the server sent back, far more specific than whatever generic error the application layer reported. In a service mesh, the equivalent step is checking the sidecar proxy's own logs (istio-proxy for Istio), since the mesh's control plane, not the application, actually owns the certificate lifecycle and its logs show handshake failures the app never even sees.
At scale, the failure that catches teams off guard most often is clock skew: TLS certificate validation checks both an expiry ('not valid after') and a start-of-validity timestamp ('not valid before'), and if a node's system clock has drifted even a few minutes ahead — commonly from an NTP daemon that silently stopped syncing, or a container with no time sync at all — it can reject a certificate that was issued moments ago as 'not yet valid' from that node's skewed point of view, while every other node in the fleet with a correctly synced clock accepts the exact same certificate without issue. This produces a maddening symptom: the failure appears only on one specific node or pod, looks identical to every other certificate error, and has nothing to do with the certificate itself being wrong.
The gotcha: because clock skew failures look identical to expiry or chain failures in the error message, and because they only reproduce on the specific node with the drifted clock, engineers frequently spend far longer than necessary re-verifying a certificate that is completely valid everywhere else, before finally thinking to check date on the failing node against a reliable time source and discovering the system clock itself, not the certificate, was the actual root cause.