Build the mental model
TLS gives you three things, and people reliably remember only two. Confidentiality means an observer on the path sees ciphertext instead of your request. Integrity means they cannot flip a bit undetected. Authenticity, the forgotten one, means you are actually talking to the server that owns the name you typed. Without authenticity the other two are worthless: an attacker who intercepts your connection and presents their own key hands you a perfectly confidential conversation with the wrong party.
Authenticity comes from certificates. A certificate binds a public key to a name and is signed by a Certificate Authority. Your browser does not trust that certificate directly; it trusts a small set of root CAs that shipped with your operating system or browser. The server presents its leaf certificate plus one or more intermediates, and the client walks the chain upward, asking: does each certificate's issuer match the next certificate's subject, is each signature valid, is each one inside its validity window, and does the top of the chain terminate at a root already in the local trust store? The crucial detail is that the root is never sent by the server. If it were, an attacker could simply send their own root and declare themselves trusted. The anchor has to be something the server cannot influence.
This is exactly why a self-signed certificate triggers a warning. Cryptographically it is fine; the encryption is real. It chains to nothing, so the browser has no basis for believing the name.
Finally, HTTPS hides less than people assume. The domain travels in cleartext in the SNI field of the ClientHello, because the server needs it to pick a certificate. Packet sizes, timing, and the destination IP are all visible. HTTPS protects the contents of the conversation, not the fact that it happened.
TLS HANDSHAKE AND THE CHAIN OF TRUST
------------------------------------
Client Server
| |
|--- ClientHello (SNI: shop.example.com) --------->|
| ^ the hostname travels in CLEARTEXT |
| |
|<-- ServerHello + certificate chain --------------|
|<-- key exchange parameters ----------------------|
| |
| [ validate: chain links, dates, hostname, |
| and a root that is already in MY trust store ] |
| |
|--- key exchange + Finished --------------------->|
|<-- Finished -------------------------------------|
|=== application data, encrypted from here ========|
CHAIN OF TRUST
+--------------------------+
| Root CA | <- pre-installed by the OS or
| "Example Root CA X1" | browser; NOT sent by server
+------------+-------------+
| signs
v
+--------------------------+
| Intermediate CA | <- sent by the server
| "Example TLS CA G2" |
+------------+-------------+
| signs
v
+--------------------------+
| Leaf certificate | <- sent by the server
| CN=shop.example.com |
+--------------------------+Connect it to a real scenario
Say you are shipping the Tutorial Platform behind a new domain and the browser shows NET::ERR_CERT_AUTHORITY_INVALID. The instinct is to blame the key or the cipher; the actual cause is almost always the chain. The most common production version of this bug is a server configured with only the leaf certificate and no intermediate. It works on your laptop, because your laptop happened to cache that intermediate from an earlier site, and it fails on a fresh phone or a CI container that never saw it. The fix is to serve the full chain file, leaf first and then intermediates with the root omitted, not to reissue anything.
The second most common version is date-related: the certificate expired, or the server's clock is wrong. A clock skewed a few hours past midnight on the expiry date turns a working site into a broken one with no code change at all. The third is a hostname mismatch, where a certificate for example.com is presented on www.example.com, which is a different name unless the SAN list says otherwise.
Work in that order: chain completeness, then dates, then names. The code below walks the same three checks a browser performs, which is why each failure message it prints maps onto a real browser error you have probably already seen.
Try the working example
from datetime import date
# A certificate chain as the server actually presents it: leaf first,
# then the intermediate that signed it. The ROOT is included here only
# so we can show the whole picture -- servers normally do NOT send it.
CHAIN = [
{"subject": "CN=shop.example.com", "issuer": "CN=Example TLS CA G2",
"not_before": date(2026, 1, 15), "not_after": date(2026, 4, 15),
"is_ca": False},
{"subject": "CN=Example TLS CA G2", "issuer": "CN=Example Root CA X1",
"not_before": date(2024, 3, 1), "not_after": date(2030, 3, 1),
"is_ca": True},
{"subject": "CN=Example Root CA X1", "issuer": "CN=Example Root CA X1",
"not_before": date(2020, 6, 1), "not_after": date(2040, 6, 1),
"is_ca": True},
]
# A self-signed certificate: it is its own issuer, and nothing above it.
SELF_SIGNED = [
{"subject": "CN=dev.internal", "issuer": "CN=dev.internal",
"not_before": date(2026, 2, 1), "not_after": date(2027, 2, 1),
"is_ca": False},
]
# The trust store ships with the OS or browser. The server cannot add
# to it -- this is the whole reason a chain means anything.
TRUST_STORE = {"CN=Example Root CA X1"}
TODAY = date(2026, 3, 1) # hardcoded so this example stays deterministic
def validate(chain, hostname, today):
problems = []
if chain[0]["subject"] != "CN=" + hostname:
problems.append("leaf subject does not match " + hostname)
for i, cert in enumerate(chain):
if not (cert["not_before"] <= today <= cert["not_after"]):
problems.append(cert["subject"] + " is outside its validity window")
if i > 0 and not cert["is_ca"]:
problems.append(cert["subject"] + " signed but is not a CA")
if i + 1 < len(chain):
if cert["issuer"] != chain[i + 1]["subject"]:
problems.append("chain break below " + cert["subject"])
anchor = chain[-1]["subject"]
if anchor not in TRUST_STORE:
problems.append("no trusted root: " + anchor + " is not in the store")
return problems
def report(name, chain, hostname):
print("chain: " + name + " (" + str(len(chain)) + " cert(s))")
for cert in chain:
print(" " + cert["subject"] + " <- signed by " + cert["issuer"])
problems = validate(chain, hostname, TODAY)
if problems:
print(" RESULT: REJECTED")
for p in problems:
print(" - " + p)
else:
print(" RESULT: TRUSTED")
print("")
print("validating as of " + TODAY.isoformat())
print("")
report("real CA chain", CHAIN, "shop.example.com")
report("self-signed", SELF_SIGNED, "dev.internal")
# Same good chain, but the clock has moved past the leaf's expiry.
print("same chain, but 'today' is 2026-06-01:")
for p in validate(CHAIN, "shop.example.com", date(2026, 6, 1)):
print(" - " + p)validating as of 2026-03-01
chain: real CA chain (3 cert(s))
CN=shop.example.com <- signed by CN=Example TLS CA G2
CN=Example TLS CA G2 <- signed by CN=Example Root CA X1
CN=Example Root CA X1 <- signed by CN=Example Root CA X1
RESULT: TRUSTED
chain: self-signed (1 cert(s))
CN=dev.internal <- signed by CN=dev.internal
RESULT: REJECTED
- no trusted root: CN=dev.internal is not in the store
same chain, but 'today' is 2026-06-01:
- CN=shop.example.com is outside its validity window5-minute try-it
Delete the intermediate certificate from CHAIN so only the leaf and root remain, and see what the validator says. Then change the leaf's subject to CN=www.shop.example.com and run it again. Write down which two real browser error messages these two failures correspond to.
One important caution
Serving only the leaf certificate and no intermediate: it works in your browser, which cached the intermediate earlier, and fails on a fresh device or a CI container.
Assuming HTTPS hides which site you visited: SNI carries the hostname in cleartext, and the destination IP address is visible regardless.
RFC 8446 - The Transport Layer Security (TLS) Protocol Version 1.3 — Computer Networking