Direct answer
Certificate validation bugs almost always come from skipping or short-circuiting one of the checks that make a certificate chain trustworthy: who signed it, is it still valid, and does it actually name the host being connected to. These bugs are usually introduced as a debugging shortcut (disabling a check to get past a local development error) that never gets re-enabled, or by hand-rolling validation logic instead of using the platform's TLS (Transport Layer Security) stack correctly. Below are five common mistakes, each with the resulting attack and the fix.
Structured elaboration
-
Skipping hostname verification. The code validates the certificate chain but never confirms the certificate's Subject Alternative Name (SAN) matches the hostname actually being connected to. Attack: an attacker with ANY valid certificate for ANY domain (one they legitimately purchased for, say, evil.com) can man-in-the-middle (MITM, intercept and impersonate) a connection to bank.com, because the code never checks that the certificate says bank.com. Fix: always verify the hostname against the SAN entries (not the deprecated Common Name field) using the platform TLS library's built-in hostname check, and never disable it as a "temporary" fix that ships.
-
Trusting expired or not-yet-valid certificates. The code skips the notBefore/notAfter validity-window check. Attack: an attacker who has compromised a private key tied to an EXPIRED certificate (which the legitimate owner may no longer be actively monitoring or rotating) can still impersonate the server if expiry is never checked. Fix: verify the current time falls within the certificate's validity window on every connection, and separately ensure the system clock itself is trustworthy, since a badly wrong clock defeats this check even when it is present.
-
Accepting self-signed certificates or disabling chain validation entirely. A "temporary" development workaround (setting an equivalent of verify=False or InsecureSkipVerify) ships to production. Attack: literally any man-in-the-middle proxy presents any self-signed certificate and it is accepted without complaint. Fix: never ship a disabled-verification code path; if local development genuinely needs it, gate it behind a build flag that cannot compile into a release build, and use a real local certificate authority (CA, the entity that issues and signs certificates) for development instead.
-
Not checking revocation status. The code validates the chain and expiry but never checks whether an intermediate or leaf certificate has been revoked via the Certificate Revocation List (CRL) or Online Certificate Status Protocol (OCSP). Attack: a certificate authority's intermediate signing key is compromised and revoked; without revocation checking, a forged certificate chained through that intermediate is silently accepted even after the compromise is public. Fix: use OCSP stapling where available, fail closed (or soft-fail with logging and alerting, depending on risk tolerance) when revocation status cannot be determined for a high-value connection, and keep the local trust store current.
-
Accepting weak or deprecated signature algorithms or key sizes. The code accepts any certificate the chain-builder considers structurally valid, without an explicit allow-list of acceptable signature algorithms and minimum key sizes. Attack: an attacker exploits a known weakness in a deprecated hash algorithm (collision attacks against certificates signed with MD5 (Message-Digest Algorithm 5, an older cryptographic hash function long known to be broken) have been demonstrated in published research) to forge a certificate that still validates against a legitimate certificate authority's signature. Fix: pin an explicit allow-list of acceptable signature algorithms and minimum key sizes, reject anything outside it, and keep that allow-list current as algorithms are deprecated.
Worked example
Mistake 1 (hostname verification) is the easiest to demonstrate concretely, and it illustrates a real historical anti-pattern: hand-rolled code that checks whether the certificate name appears as a SUBSTRING of the requested host (or vice versa) instead of doing an exact or wildcard match.
python
def broken_match(requested_host: str, cert_name: str) -> bool:
"""VULNERABLE anti-pattern seen in real hand-rolled validation code:
treats the certificate name as matching if it appears anywhere in (or
the requested host appears anywhere in) the certificate's name field."""
return cert_name in requested_host or requested_host in cert_name
def correct_match(requested_host: str, cert_name: str) -> bool:
"""RFC 6125 style matching: exact match, or a single leading wildcard
label matching exactly one label (never matches the bare domain, never
matches across multiple labels)."""
requested_host = requested_host.lower()
cert_name = cert_name.lower()
if not cert_name.startswith("*."):
return requested_host == cert_name
wildcard_suffix = cert_name[1:] # ".bank.com"
if not requested_host.endswith(wildcard_suffix):
return False
remaining_label = requested_host[: -len(wildcard_suffix)]
return remaining_label != "" and "." not in remaining_label
if __name__ == "__main__":
cases = [
# (requested_host, cert_name, description)
("bank.com", "bank.com", "exact match, legitimate"),
("www.bank.com", "*.bank.com", "wildcard match, legitimate"),
("bank.com", "*.bank.com", "bare domain against a wildcard cert (must NOT match)"),
("a.b.bank.com", "*.bank.com", "wildcard must not span multiple labels"),
("bank.com.evil-attacker.com", "bank.com",
"attacker registers bank.com.evil-attacker.com and gets a REAL cert for it"),
("bank.com", "bank.com.evil-attacker.com",
"same attack, arguments reversed (both orderings appear in real bugs)"),
]
print(f"{'requested_host':32} {'cert_name':24} {'broken':>8} {'correct':>8} description")
for host, cert, desc in cases:
b = broken_match(host, cert)
c = correct_match(host, cert)
print(f"{host:32} {cert:24} {str(b):>8} {str(c):>8} {desc}")
# the concrete failure: the substring check accepts the attacker's cert
attack_host, attacker_cert = "bank.com", "bank.com.evil-attacker.com"
print(f"\nattacker cert '{attacker_cert}' accepted for host '{attack_host}' "
f"by broken_match: {broken_match(attack_host, attacker_cert)} (must be True to prove the bug)")
print(f"same attack rejected by correct_match: "
f"{correct_match(attack_host, attacker_cert)} (must be False)")
Running this against an attacker who registers bank.com.evil-attacker.com (a domain they legitimately own and can get a real certificate for) and presents it while the victim believes they are connecting to bank.com:
requested_host cert_name broken correct description
bank.com bank.com True True exact match, legitimate
www.bank.com *.bank.com False True wildcard match, legitimate
bank.com *.bank.com True False bare domain against a wildcard cert (must NOT match)
a.b.bank.com *.bank.com False False wildcard must not span multiple labels
bank.com.evil-attacker.com bank.com True False attacker registers bank.com.evil-attacker.com and gets a REAL cert for it
bank.com bank.com.evil-attacker.com True False same attack, arguments reversed (both orderings appear in real bugs)
attacker cert 'bank.com.evil-attacker.com' accepted for host 'bank.com' by broken_match: True (must be True to prove the bug)
same attack rejected by correct_match: False (must be False)
The substring check is not just insecure, it also happens to reject a legitimate wildcard match (www.bank.com against *.bank.com), which is a realistic reminder that hand-rolled security checks tend to be both unsafe AND unreliable at the same time.
Trade-offs and pitfalls
Fail-open versus fail-closed on revocation checking is a genuine tension: OCSP responders can be slow or unavailable, and a strict fail-closed policy turns an OCSP outage into a service outage, while soft-failing weakens the security guarantee; OCSP stapling (the server fetches and caches the OCSP response itself) reduces this tension considerably. Certificate pinning, if used, should pin to a CA or a backup key rather than a single leaf certificate, otherwise a legitimate certificate rotation breaks every pinned client. The recurring root cause across all five mistakes is the same: a check disabled "temporarily" for local development that ships to production; the fix is a build-time gate that makes shipping that code path structurally impossible, not a code review reminder.