What TLS actually protects on an HTTPS connection

Load a page over plain http:// on a network you don’t control, an airport or a coffee-shop router, and everything you send travels as readable text. The URL path, the cookies, the form fields, the password in a login POST: anyone on that network with a packet sniffer reads it as it goes past. TLS, Transport Layer Security, is the protocol that stops that. It sits between TCP and the application protocol, so by the time HTTP starts talking, the connection is already encrypted and tied to a verified server identity. HTTPS is HTTP run over that connection instead of a plain one.

TLS bundles three guarantees, and they only hold together. Encryption means a network observer sees ciphertext and nothing else. Integrity means a bit flipped in transit is detected and the connection drops instead of delivering altered data. Authentication means the client can confirm it reached example.com and not whoever answered on that IP address first. Lose any one and the other two stop being worth much: an encrypted channel to an impostor is not a secure channel.

How the TLS handshake sets up an encrypted channel

Before any HTTP request goes out, client and server run a handshake that agrees on a shared secret key without ever putting that key on the wire. TLS 1.3, the current version and the one you should be running, does it in one round trip where TLS 1.2 needed two.

1
2
3
4
5
6
7
8
9
Client                                           Server
  |--- ClientHello + key_share ------------------->|
  |    (supported versions, cipher suites,         |
  |     a guessed key-exchange group)              |
  |<-- ServerHello + key_share --------------------|
  |<-- EncryptedExtensions, Certificate,           |
  |    CertificateVerify, Finished ----------------|
  |--- Finished ---------------------------------->|
  |======== application data, encrypted ===========|

The client sends a ClientHello: the TLS versions and cipher suites it supports, a random nonce, and a guess at which key-exchange group the server will pick, sent as a key_share in the same message. That guess is the TLS 1.3 shortcut. The server answers with its own key_share in the ServerHello, and both sides run a Diffie-Hellman exchange over those shares to derive the same symmetric session key independently. The key itself never crosses the network.

From that point everything else is already encrypted under that session key: the server’s certificate, its proof of holding the matching private key, the Finished messages that checksum the whole handshake. One round trip, and the connection is live.

TLS 1.2 needed a second round trip because the key exchange and the cipher negotiation ran as separate steps instead of overlapping. That extra trip is pure latency. On a link with 150ms RTT it adds 150ms to every new HTTPS connection, which is why TLS 1.3 showed up in page-load numbers and not only in security posture.

TLS 1.2TLS 1.3
Round trips to first encrypted byte21
Key exchangeNegotiated after cipher suiteGuessed and sent with ClientHello
Renegotiation mid-sessionAllowedRemoved
Static RSA key exchangeAllowedRemoved (forward secrecy mandatory)
Weak ciphers (RC4, CBC-mode legacy)AllowedRemoved

What a certificate proves and how the chain of trust works

The key exchange handles encryption. Proving the server really is example.com is the certificate’s job. A certificate binds a public key to a domain name and carries the signature of a Certificate Authority (CA), an organization whose own public key already ships in your OS and browser trust stores.

In practice the chain runs three deep. A root CA certificate, self-signed and preinstalled everywhere, signs an intermediate CA certificate. The intermediate signs the leaf certificate, the one for your domain. Servers almost never present a certificate signed directly by a root: they serve the leaf plus the intermediate, and the client chains that back to a root it already trusts.

Forget to serve the intermediate and you get the failure that looks like a ghost. Browsers holding a cached copy of that intermediate connect fine, so the site works on your laptop; everyone else gets a trust error. It is one of the most common TLS misconfigurations in production, and it never reproduces on the machine you tested from.

A certificate also carries a validity window (notBefore / notAfter) and the hostnames it covers, listed in the Subject Alternative Name (SAN) extension. SAN is the field clients actually read now; the old Common Name field is vestigial. A certificate for example.com does not cover api.example.com unless that name is in the SAN list too, or the certificate is a wildcard for *.example.com.

How to inspect a certificate with openssl s_client

You don’t need a browser to see any of this. openssl s_client opens a raw TLS connection and dumps what it negotiated:

1
openssl s_client -connect example.com:443 -servername example.com </dev/null

-servername sets SNI (Server Name Indication), the hostname sent unencrypted in the ClientHello so a server hosting many domains on one IP knows which certificate to present. Leave it off and a multi-tenant server has nothing to pick on.

For the expiry date alone rather than the whole handshake dump, pipe into openssl x509:

1
2
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -dates -subject -issuer

That prints notBefore, notAfter, the subject (the domain the certificate was issued for), and the issuer (the CA that signed it). For a monitoring script that only needs a pass/fail, -checkend takes a window in seconds and sets the exit code:

1
2
3
4
openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -checkend 2592000 \
  && echo "OK: valid for at least 30 more days" \
  || echo "WARNING: expires within 30 days"

curl -v is quicker for a sanity check, since it prints the negotiated TLS version and the certificate chain as part of its verbose handshake trace:

1
curl -v https://example.com 2>&1 | grep -E "SSL connection|subject:|expire date"

How to get HTTPS on localhost with mkcert

A raw self-signed certificate, one you generate and sign yourself instead of getting from a CA, gives you encryption but not authentication. Nothing vouches for it, so every browser shows a warning. Fine for a quick openssl test. Irritating for local development, where you want https://myapp.local to load without a click-through.

mkcert solves that by generating a local CA, installing it into your OS and browser trust stores, and issuing certificates signed by it:

1
2
mkcert -install                          # generates and trusts a local CA
mkcert localhost 127.0.0.1 myapp.local   # issues a cert for these names

You get a .pem certificate and key pair in the current directory, trusted by every browser on the machine because they now trust the CA mkcert installed. Point your dev server’s TLS config at those two files and the warning is gone.

Keep rootCA-key.pem out of version control. Anyone holding mkcert’s local CA private key can mint a certificate for any domain that your machine will trust, which is the same class of exposure described in /en/posts/docker/docker-environment-variables-secrets/ for environment-variable secrets: a private key that leaks is a credential that leaked.

How to get a production certificate with Let’s Encrypt and certbot

In production you need a certificate signed by a CA every visitor’s browser already trusts, and Let’s Encrypt is the free automated option most sites use. certbot speaks the ACME protocol to Let’s Encrypt’s servers and, with a webserver plugin, configures Nginx or Apache for you:

1
sudo certbot --nginx -d example.com -d www.example.com

That proves you control the domain (by serving a token certbot places, or via a DNS record, depending on the challenge type), obtains the certificate, and writes the ssl_certificate and ssl_certificate_key directives into your Nginx config.

Let’s Encrypt certificates last 90 days. The short window is deliberate: it makes automated renewal the only workable path. Certbot installs a systemd timer, or a cron job on older systems, that runs twice a day and renews anything within 30 days of expiry:

1
sudo certbot renew --deploy-hook "systemctl reload nginx"

--deploy-hook fires only on an actual renewal, not on every timer run, so Nginx is not reloaded twice a day for nothing. Test the pipeline before you trust it unattended:

1
sudo certbot renew --dry-run

How TLS termination works at a reverse proxy or CDN

Most services never terminate TLS inside the application process. A /en/posts/networking/what-is-a-reverse-proxy/ in front of your app is the usual home for it: Nginx or Caddy holds the certificate, decrypts the incoming HTTPS connection, and forwards the request to the backend over plain HTTP on the internal network. Your application code never sees a certificate.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8080;
    }
}

fullchain.pem is the leaf certificate with the intermediate already appended, the chain-of-trust file ready to serve as-is. Use it rather than cert.pem, which is the leaf on its own and reintroduces the missing-intermediate failure from earlier.

A /en/posts/cloud/what-is-a-cdn/ does the same job one hop further out. It terminates TLS at the edge PoP nearest the visitor, then opens a second TLS connection back to your origin. Two connections, two handshakes, and the certificate the visitor’s browser validates is the CDN’s edge certificate. Your origin certificate only has to satisfy the CDN.

That split decides where you debug. If curl straight at the origin shows a valid certificate but the public domain does not, the problem sits at the proxy or CDN layer and no amount of reading application logs will find it.

What HSTS adds on top of TLS

TLS secures a connection once it is HTTPS. It does nothing about the first request, which can still go out over plain http:// when a user types a bare domain or follows an old link, and that first request is exactly where an attacker on the path can intercept and downgrade. Strict-Transport-Security closes the gap: after one successful HTTPS visit, the browser rewrites every later request to that domain to HTTPS before it leaves the machine.

1
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

max-age is in seconds, and 63072000 is two years, comfortably over the one-year floor that preload submission requires. includeSubDomains extends the policy to every subdomain. preload marks the domain as eligible for the list shipped inside Chrome, Firefox, and Safari, whose entries get the HTTPS-only rewrite from the very first request ever made, with no prior visit needed.

Add preload only once every subdomain genuinely serves HTTPS. Getting off the preload list takes months to propagate back out through browser releases, and until it does, any subdomain that cannot do HTTPS is unreachable.

When TLS is not enough, and what to check first

TLS verifies the server to the client. It says nothing about what that server does with your request afterward, and it does not stop a phished user from typing a password into a convincing fake domain that holds a perfectly valid certificate. Let’s Encrypt will issue for examp1e.com as readily as for example.com. A padlock means the connection is private, not that the other end is honest.

Three failures account for most “the site is down” reports that turn out to be TLS misconfiguration: an expired certificate, a missing intermediate, and a SAN list that does not cover the hostname being requested. All three are visible in the openssl s_client output above, before you open a single application log.

If you terminate TLS yourself, put that -checkend command into a cron job or a monitoring alert today. The certificate that takes a site down is the one nobody remembered was there.