HTTPS and TLS Explained: The Handshake, the Certificates, and a Config That Scores A+
Every site needs HTTPS, and most engineers can wire up certbot in five minutes. The problem is what they copy along with it: handshake explanations that describe a protocol nobody has negotiated since 2018, an Nginx directive that logs a deprecation warning on every reload, and a “security headers” block whose first entry is a header browsers deleted years ago — one that can actively introduce the vulnerability it claims to stop. TLS is the most successful security protocol ever deployed, and the tooling around it is genuinely excellent. The advice surrounding it has rotted. This is what a correct, current setup looks like and why each piece is there.
What HTTPS Actually Guarantees
TLS gives you three properties on the connection between a client and a server:
- Confidentiality — a network observer (your ISP, a coffee-shop Wi-Fi operator, a transparent proxy) sees ciphertext, not your traffic.
- Integrity — that ciphertext cannot be silently modified in flight; tampering breaks the authenticated-encryption tag and the connection aborts.
- Authentication — you are talking to the server that controls the private key for the certificate, and the certificate is vouched for by a CA your system trusts.
It is just as important to be clear about what HTTPS does not give you. It says nothing about whether the server itself is honest, whether your data is safe once it arrives, or whether the application has bugs. A phishing site can hold a perfectly valid Let’s Encrypt certificate for paypa1-login.com; the padlock means “encrypted to the holder of this name,” not “trustworthy.” It also leaks more than people assume: the destination IP is visible, and the hostname still leaks via the TLS SNI field unless Encrypted Client Hello (ECH) is in play, which in 2026 is supported by major browsers and Cloudflare but is far from universal. HTTPS protects the channel. The endpoints are still your problem.
The TLS 1.3 Handshake
The handshake most articles draw is TLS 1.2: two round trips, a separate key-exchange step, a long cipher menu. TLS 1.3 (RFC 8446, standardized in 2018) collapses that to one round trip, removes every cipher suite that wasn’t forward-secret AEAD, and encrypts the certificate itself. Here is what actually happens:
Client Server
| |
|--- ClientHello ------------------------------------->|
| key_share (X25519 ephemeral pubkey) |
| supported_versions: TLS 1.3 |
| signature_algorithms, SNI, ALPN (h2/h3) |
| |
| [server now has enough to derive keys] |
| |
|<-- ServerHello -------------------------------------|
| key_share (server ephemeral pubkey) |
| {EncryptedExtensions} |
| {Certificate} <- encrypted |
| {CertificateVerify} <- signs the handshake |
| {Finished} |
| |
|--- {Finished} -------------------------------------->|
| |
|====== Application Data (1-RTT) ======================|
The two ephemeral key_share values are combined with Elliptic-Curve Diffie-Hellman (X25519 is the common curve) to derive a shared secret neither party transmitted. The server’s long-term certificate key is used only to sign the handshake (CertificateVerify), proving it owns the name — it never encrypts the session key. That separation is what gives you forward secrecy: if the server’s private key leaks next year, captured traffic from today stays unreadable, because the ephemeral keys that actually protected it were discarded when the connection closed.
TLS 1.3 also offers 0-RTT resumption, where a returning client sends application data in its very first flight. It is faster, but 0-RTT data is replayable by an attacker, so it must only carry idempotent requests (a GET, never a “transfer money” POST). Treat it as an optimization with a sharp edge, not a default to flip on blindly.
Certificates and the Chain of Trust
A certificate binds a public key to a hostname, signed by a Certificate Authority. Your browser ships with a root store of CAs it trusts; the server sends a chain (leaf → intermediate(s)) that links its certificate up to one of those roots. The leaf is signed by an intermediate, the intermediate by the root, and the root is the trust anchor already on your machine.
For nearly every use case the answer is Let’s Encrypt (or another ACME CA like ZeroSSL or Google Trust Services). ACME automates the entire issuance and renewal flow, and certificates are valid for 90 days specifically to force automation — a cert you renew by hand every year is a cert you will eventually forget. The industry is moving toward even shorter lifetimes; Let’s Encrypt began offering 6-day certificates in 2025, which only makes automation more mandatory.
|
|
If you terminate TLS at a reverse proxy, prefer a tool that treats ACME as a first-class feature. Caddy and Traefik both obtain and renew certificates automatically with no cron job to forget — see the Traefik guide for a ForwardAuth-and-ACME setup. On Kubernetes, cert-manager fills the same role. The DNS-01 challenge (proving control via a TXT record) is what you want for wildcard certificates and for hosts that aren’t reachable from the public internet.
One operational note: protect the private key like any other secret. It lives in /etc/letsencrypt/live/<domain>/privkey.pem as root-only, and it should never land in a git repo or a container image layer. If you manage keys across machines, see secrets management.
A Modern Nginx Configuration
Here is a current config. The differences from the canonical copy-paste are small but they matter — and the first one is a deprecation every up-to-date Nginx will warn you about.
|
|
Three things worth calling out. listen 443 ssl http2; is deprecated — since Nginx 1.25.1 http2 is a standalone directive, and the old form prints a warning and will eventually break. OCSP stapling lets the server present a CA-signed “this cert is still valid” proof, sparing the client a separate, privacy-leaking round trip to the CA. ssl_prefer_server_ciphers off is the modern recommendation: well-behaved clients already prefer the best mutually-supported suite, and forcing server preference can push mobile clients off ChaCha20 (which is faster than AES on phones without AES hardware). Don’t hand-roll the cipher string from memory — generate it from the Mozilla SSL Configuration Generator and pick the “Intermediate” profile.
Security Headers That Earn Their Place in 2026
This is where the old tutorial actively hurt you. The header block everyone copies leads with X-XSS-Protection "1; mode=block", and that advice is not just outdated — it is harmful.
X-XSS-Protection controlled the browser’s built-in XSS Auditor, a heuristic filter. Chrome removed the Auditor entirely in 2019, Edge followed, and Firefox never implemented it. The header does nothing on a modern browser except in its legacy filtering modes, where it was shown to create vulnerabilities — attackers could abuse the filter to selectively disable legitimate scripts or leak cross-origin information. The current consensus from OWASP, MDN, and Google is explicit: do not enable it. If you set it at all, set it to 0 to force-disable any legacy behavior. Your actual defense against XSS is a Content-Security-Policy, not a deleted heuristic.
Here is a header set that reflects current guidance:
|
|
| Header | Status in 2026 | What to do |
|---|---|---|
Strict-Transport-Security |
Essential | Set long max-age + includeSubDomains; consider preload |
Content-Security-Policy |
Essential | Your primary XSS/injection control; build it deliberately |
X-Content-Type-Options |
Keep | nosniff, one line, no downside |
Referrer-Policy |
Keep | strict-origin-when-cross-origin |
Permissions-Policy |
Keep | Deny APIs you don’t use |
X-Frame-Options |
Legacy but fine | Superseded by CSP frame-ancestors; harmless to keep |
X-XSS-Protection |
Deprecated/harmful | Remove, or set to 0. Never 1; mode=block |
Expect-CT |
Dead | Obsolete since 2023; CT is enforced unconditionally now. Remove it |
One Nginx footgun: add_header does not inherit into a location block that defines its own add_header. If you set headers at the server level and then add even one header inside a location, you silently lose all the server-level ones in that location. Define them once where they apply, and verify with curl -I.
Building a Content-Security-Policy
CSP is the one header you actually build rather than copy. It is an allowlist for where a page may load resources from, and a strict policy is what neutralizes an injected <script> even after an attacker gets markup onto your page. The directives you’ll set most often:
| Directive | Controls |
|---|---|
default-src |
Fallback for any *-src you don’t set explicitly |
script-src |
Where JavaScript may load from — the highest-value one |
style-src |
Stylesheet sources |
img-src |
Image sources (data: and https: are common additions) |
connect-src |
fetch/XHR/WebSocket endpoints |
frame-ancestors |
Who may embed you in a frame — the modern X-Frame-Options |
Roll it out in report-only mode first, so violations are logged rather than blocked while you discover the legitimate sources your app actually uses:
|
|
Once the reports go quiet, swap the header name to the enforcing Content-Security-Policy. Avoid 'unsafe-inline' on script-src — it reopens exactly the injection hole CSP exists to close; use nonces or hashes for the inline scripts you genuinely need.
If you terminate TLS at the application instead of Nginx, set the same headers there. Express apps use helmet, which ships sensible defaults in one line; Django exposes them through SECURE_* settings plus django-csp:
|
|
|
|
Whatever sets the headers, grade the result the same way — securityheaders.com and the Mozilla HTTP Observatory both score a live site and tell you exactly what’s missing.
Testing What You Shipped
Never trust a TLS config you haven’t measured. Three tools, in order of depth:
|
|
testssl.sh is the one to internalize — it checks protocol versions, cipher strength, certificate chain, OCSP stapling, HSTS, and known vulnerabilities (Heartbleed, ROBOT, and friends) in a single run, and it works against internal hosts SSL Labs can’t reach.
For expiry, don’t rely on remembering. Monitor it the same way you monitor anything else, and alert with real lead time:
|
|
With 90-day (soon 6-day) certificates the renewal timer is the thing that breaks, not the cert math — so the highest-value alert is “certbot’s renewal hasn’t run successfully in N days,” not just “expiry is near.”
Common Mistakes
- Mixed content. One
http://asset on anhttps://page and the browser blocks it or flashes a warning. Audit with the console; serve everything over HTTPS. - Serving the leaf without intermediates. Works in your browser (it cached the intermediate), fails for others. Always serve
fullchain.pem, and let SSL Labs confirm the chain is complete. - HSTS before you’re ready.
Strict-Transport-Securityis a promise the browser remembers formax-age. Turn it on before HTTPS works everywhere — including every subdomain underincludeSubDomains— and you can lock users out. Thepreloadlist is even harder to back out of; opt in only when you’re certain. - TLS terminated, backend in cleartext. Encrypting client→proxy and then sending proxy→app in plaintext across an untrusted network defeats the point. Encrypt internal hops too, or keep them on a trusted segment.
- Copy-pasted dead headers.
X-XSS-Protection: 1; mode=blockandExpect-CTmake a config look thorough while doing nothing — or worse. Currency is part of correctness.
Verdict
The mechanics of HTTPS are a solved problem: TLS 1.3 gives you a one-round-trip handshake with forward secrecy by default, ACME makes certificates free and automatic, and a reverse proxy like Caddy or Traefik can run the whole lifecycle with zero cron jobs. The failure mode in 2026 is not “TLS is hard” — it’s stale advice. The two things to get right are the handshake model (ephemeral keys protect the session; the certificate only authenticates, which is why forward secrecy holds even if the key later leaks) and the header hygiene (CSP is your real XSS defense; X-XSS-Protection is deprecated and should be removed or set to 0, and listen 443 ssl http2; is deprecated Nginx syntax).
Concretely: terminate with TLS 1.2/1.3, generate your cipher list from Mozilla’s tool rather than memory, staple OCSP, set HSTS only once HTTPS is truly everywhere, lead your security headers with a real Content-Security-Policy, and verify the result with testssl.sh and curl -I instead of trusting the snippet you pasted. HTTPS is the baseline — but only when it’s configured against this decade’s guidance, not the last one’s.
Sources
- RFC 8446 — The Transport Layer Security (TLS) Protocol Version 1.3
- Mozilla SSL Configuration Generator
- MDN — X-XSS-Protection (deprecated; do not use)
- OWASP Secure Headers Project
- helmet — security headers middleware for Express
- MDN — Content-Security-Policy
- MDN — Strict-Transport-Security (HSTS)
- Let’s Encrypt — How It Works and ACME
- Nginx —
http2directive (ngx_http_v2_module) - testssl.sh — TLS/SSL command-line scanner
- Qualys SSL Labs — SSL Server Test
Comments