LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

HTTPS and TLS Explained: The Handshake, the Certificates, and a Config That Scores A+

httpstlssslcertificateslets-encryptnginxsecurity-headershstsacmeweb-security

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:

  1. Confidentiality — a network observer (your ISP, a coffee-shop Wi-Fi operator, a transparent proxy) sees ciphertext, not your traffic.
  2. Integrity — that ciphertext cannot be silently modified in flight; tampering breaks the authenticated-encryption tag and the connection aborts.
  3. 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.

1
2
3
4
5
6
# Nginx plugin: obtains the cert AND edits your server block
sudo certbot --nginx -d example.com -d www.example.com

# Verify the renewal timer is active (certbot installs a systemd timer or cron job)
systemctl list-timers | grep certbot
sudo certbot renew --dry-run

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
server {
    # Nginx 1.25.1+ : http2 is its own directive, NOT a listen parameter.
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;

    server_name example.com;

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

    # TLS 1.2 as the floor, 1.3 preferred. 1.0/1.1 are removed.
    ssl_protocols TLSv1.2 TLSv1.3;

    # Cipher list applies to TLS 1.2; TLS 1.3 suites are fixed by the RFC.
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;   # let modern clients pick; respects ChaCha on mobile

    # Session resumption without server-side state bloat
    ssl_session_timeout 1d;
    ssl_session_cache shared:MozSSL:10m;
    ssl_session_tickets off;

    # OCSP stapling: server fetches and attaches the revocation proof
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 9.9.9.9 valid=300s;

    # HSTS: 2 years, all subdomains, preload-eligible
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

    location / {
        # ... your app ...
    }
}

# Plain HTTP exists only to redirect to HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Force-disable the legacy XSS Auditor (do NOT use "1; mode=block")
add_header X-XSS-Protection "0" always;

# Stop MIME-type sniffing
add_header X-Content-Type-Options "nosniff" always;

# Clickjacking defense. frame-ancestors in CSP is the modern equivalent.
add_header X-Frame-Options "SAMEORIGIN" always;

# The real XSS defense. Start in report-only, then enforce.
add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'" always;

# Don't leak full URLs to third parties
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# Lock down powerful APIs you don't use
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
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:

1
2
# Logs violations to the report endpoint without breaking anything yet
add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report" always;

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:

1
2
3
// Express
const helmet = require('helmet');
app.use(helmet());   // HSTS, nosniff, frame-ancestors, a baseline CSP, and more
1
2
3
4
5
6
7
# Django settings.py
SECURE_HSTS_SECONDS = 63072000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "SAMEORIGIN"
# Content-Security-Policy via the django-csp 4.x middleware
CONTENT_SECURITY_POLICY = {"DIRECTIVES": {"default-src": ["'self'"]}}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 1. Authoritative external grade (aim for A or A+)
#    https://www.ssllabs.com/ssltest/

# 2. Local, scriptable, offline-capable — the best CLI scanner
docker run --rm -ti drwetter/testssl.sh https://example.com

# 3. Quick manual checks
openssl s_client -connect example.com:443 -servername example.com -tls1_3 </dev/null
echo | openssl s_client -connect example.com:443 2>/dev/null \
  | openssl x509 -noout -dates -subject -issuer

# 4. Confirm headers actually land (and aren't dropped in a location block)
curl -sI https://example.com | grep -iE 'strict-transport|content-security|x-content-type'

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:

1
2
3
4
5
6
7
8
#!/usr/bin/env bash
set -euo pipefail
domain="${1:?usage: cert-check <domain>}"
end=$(echo | openssl s_client -connect "$domain":443 -servername "$domain" 2>/dev/null \
      | openssl x509 -noout -enddate | cut -d= -f2)
days_left=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
echo "$domain expires in $days_left days ($end)"
(( days_left < 21 )) && echo "WARN: renew $domain now" >&2 || true

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 an https:// 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-Security is a promise the browser remembers for max-age. Turn it on before HTTPS works everywhere — including every subdomain under includeSubDomains — and you can lock users out. The preload list 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=block and Expect-CT make 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

Comments