Get listed

Man in the middle: TLS 1.3, HSTS, pin only if you rotate

A letter slit open on a middle desk between two sealed envelopes.

A man-in-the-middle attack is someone who can read or change bytes on the path, usually because TLS was optional or pinned badly.

HPKP is gone from Chrome. Certificate transparency and a modern TLS config are the replacement, not a custom pin you will forget to rotate. A captive portal on HTTP is still a classroom demo of the same class.

The usual mistake is pinning a leaf cert in a mobile app and then shipping an outage when the cert rotates.

This page is the MITM shapes that still exist after HPKP, and the TLS settings that close the easy ones.

Chrome 72 removed HTTP Public Key Pinning in January 2019. MDN’s Public-Key-Pins page on 22 August 2026: the header is marked deprecated, and no current browser honors it. Telling people to watch for a hotel captive portal, or to click through a warning, is not a control.

A man-in-the-middle sitting on the path can read or change bytes if the hop is HTTP, if the handshake can fall back to a broken version, or if the client accepts any certificate a public CA will mint. The fix is the protocol, the header, and a pin or TLSA check in software you operate. It is not a training slide. Pair this page with the TLS guide for ACME and ciphers, with HTTP headers for the rest of the response, and with the Ubuntu host guide for who binds 443.

TLS 1.3 on the listener you own

TLS 1.3 is RFC 8446. The handshake is shorter. The cipher list is AEAD only. The protocol drops renegotiation and static RSA key exchange. TLS 1.3 bar we already keep on this site: TLSRef 6.0, dated 3 May 2026, removed the Old profile. Modern is 1.3 only. Intermediate is 1.2 plus 1.3 for leftover clients. Neither profile lists 1.0 or 1.1. RFC 8996 deprecated those two in March 2021.

A public 443 that still lists TLSv1 is offering a downgrade door. Turn the old versions off at the process you admin. Do not leave them on so a scanner can connect. The TLS page has the nginx Intermediate cipher line and the Caddyfile. This page is the path around the handshake, not a second copy of that file.

# /etc/nginx/conf.d/tls.conf
# Modern: 1.3 only. Drop ssl_ciphers. TLS 1.3 suites are not selected there.
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

If access logs still show 1.2 handshakes you must keep, switch that one line to ssl_protocols TLSv1.3 TLSv1.2; and copy the Intermediate cipher list from the TLS guide. Measure a quarter, then drop 1.2. Do not keep 1.0 “for a vendor portal.” Put that portal behind a proxy you own, or retire it.

HSTS closes the first HTTP hop

Strict-Transport-Security tells the browser to skip http:// on this host for max-age seconds. TLSRef sets max-age=63072000, two years, on both profiles. Helmet 8 still ships one year. Prefer two years once 443 is stable. includeSubDomains locks every label under the zone. Turn it on only after mail, staging, and the forgotten wiki also speak HTTPS. preload is a request to enter the browser-shipped list at hstspreload.org. Submit only after max-age is at least 31536000, includeSubDomains is on, and you can live with a year of browser updates if you need to undo it.

The first visit, and any client with an empty HSTS cache, still needs a 301 from port 80. HSTS is the next visit. Preload is the visit before the cache exists. Send HSTS on the HTTPS response, not as your only trick on the HTTP listener. A mixed-content page that still fetches a script over http: teaches the browser that cleartext is fine. Search the repo for http:// and treat a hit in a script src, a form action, or an API base URL as a defect. Helmet can emit the header. It cannot fix a hardcoded http:// in the bundle.

server {
 listen 80;
 server_name your-app.example;
 return 301 https://$host$request_uri;
}
The browser never starts HTTP after HSTS. The listener refuses deprecated versions. A pin lives in a binary you update.
BROWSER HSTS cache or preload
 never starts http:// on this host

LISTEN ssl_protocols TLSv1.3;
 no TLSv1, no TLSv1.1
 HSTS on the 443 response

PIN only in an app you ship
 SPKI hash you can rotate
 skip if you cannot verify

DANE TLSA plus DNSSEC
 only if that client checks

Pin in an app you release and rotate

HPKP was a header that told browsers to remember a public key. Operators bricked themselves. Chrome removed it. Do not send Public-Key-Pins. Expect-CT is gone the same way. Certificate Transparency is now a CA requirement, not a header you set to feel busy.

A pin still belongs in a mobile app, a CLI, or a backend that calls your-app.example and that you can ship a new build for. The pin is a SHA-256 of the subject public key info, not the whole cert, so a renewal that keeps the same key still matches. You need a second pin you can promote, and a store update path that lands before the first pin dies. If you cannot name that path, do not pin. Fail closed on mismatch. Fail open is the same as no pin.

const tls = require("tls");
const https = require("https");
const crypto = require("crypto");

const PIN_SHA256 = process.env.TLS_PIN_SHA256;
const PIN_BACKUP = process.env.TLS_PIN_BACKUP;
const pins = new Set([PIN_SHA256, PIN_BACKUP].filter(Boolean));

function pinnedIdentity(host, cert) {
 const err = tls.checkServerIdentity(host, cert);
 if (err) return err;
 if (!cert.pubkey || pins.size === 0) {
 return new Error("pin missing");
 }
 const hash = crypto.createHash("sha256").update(cert.pubkey).digest("base64");
 if (!pins.has(hash)) return new Error("pin mismatch");
 return undefined;
}

const agent = new https.Agent({
 checkServerIdentity: pinnedIdentity,
});

// https.request({ hostname: "your-app.example", agent, path: "/health" },...)

Compute PIN_SHA256 from a cert you already issued, on a host you admin:

# SPKI SHA-256, base64, from the leaf you serve today
echo | openssl s_client -connect 127.0.0.1:443 -servername your-app.example 2>/dev/null \
 | openssl x509 -pubkey -noout \
 | openssl pkey -pubin -outform der \
 | openssl dgst -sha256 -binary \
 | openssl base64

Put the output in TLS_PIN_SHA256. Mint a backup key, issue a second cert you are not serving yet, store that hash in TLS_PIN_BACKUP, and keep the private key offline. When you rotate, serve the backup, ship a build that swaps the names, then retire the old pin. If you cannot hold two keys and a store release, leave pinning off and stay on HSTS plus a 1.3 listener.

DANE only when the client checks TLSA

DANE publishes a TLSA record under _443._tcp.your-app.example and signs the zone with DNSSEC. RFC 6698 is the protocol. RFC 7671 is the operational subset. A client that looks up that record and verifies the DNSSEC chain can reject a cert a rogue CA minted. A browser in August 2026 does not do that for HTTPS. current Chromium and Firefox feature pages I could find for native HTTPS DANE. Treat DANE as unavailable for a public website until your user agent documents the check.

DANE is real where you own the client and the resolver path. Mail is the working example: a Postfix or Exim box with smtp_tls_security_level = dane will use TLSA when DNSSEC validates. For an HTTPS API, a Go or Rust worker that you wrote can do the same lookup. The record is not a control if the only client is Chrome.

# Publish only after DNSSEC is signed and a client you own will check it.
# Usage 3, selector 1, matching type 1: SHA-256 of the SPKI.
# _443._tcp.your-app.example. 3600 IN TLSA 3 1 1 <hex of PIN_SHA256>

Usage 3 is DANE-EE: the leaf SPKI is the answer. That matches the pin you already computed. If you cannot prove the stub resolver on the client validates DNSSEC, the TLSA row is a comment in the zone. Do not advertise DANE on a marketing page as a browser lock. Put the energy into HSTS preload and the 1.3 line.

ControlWho enforces itUse when
TLS 1.3 listenerYour nginx or CaddyEvery public 443
HSTS plus preloadThe browserThe zone is HTTPS everywhere
SPKI pinAn app you shipYou can rotate two hashes
DANE TLSAA client that checks DNSSECMail, or a worker you wrote

Do not make users trust a proxy

That prompt is the middlebox asking to become a CA on the device. Once the extra root is in the store, every site the person visits can be decrypted on that path. That is the failure mode this article is named for. It is not a mitigation.

If you run the middlebox, terminate TLS on a process you admin, re-encrypt to the origin with a cert you can name, and keep HSTS on the public name. Do not disable HSTS so the inspection box can strip it. Do not ask consumers to lower the bar so a hotel portal can inject ads. A captive portal belongs on its own hostname with a clear HTTP landing page, not as a fake certificate for your-app.example.

Certificate Transparency plus CA distrust is the public-web answer to a rogue public CA. You cannot CT-log a private inspection cert, and you should not. Keep that cert off devices you do not own.

Prove the handshake on your name

You are not walking an intercept against someone else’s host. You are proving your listener refused 1.0, sent HSTS, and that a client you own rejected a pin mismatch.

  1. ssl_protocols lists 1.3, and 1.2 only if you chose Intermediate.
  2. openssl s_client against your own name with -tls1 fails. The same command with -tls1_3 prints a session.
  3. curl -sI on your HTTPS origin shows Strict-Transport-Security and no mixed-content warning in DevTools.
  4. A canary request through the agent above with TLS_PIN_SHA256 emptied must throw pin missing. A request with a hash you did not mint must throw pin mismatch.
# Expect this to fail. 1.0 must not negotiate.
echo | openssl s_client -connect 127.0.0.1:443 -tls1 -servername your-app.example
# Expect TLS 1.3 and your cert.
echo | openssl s_client -connect 127.0.0.1:443 -tls1_3 -servername your-app.example
curl -sI "https://your-app.example/" | grep -i strict-transport-security

SSL Labs or testssl.sh against a name you own is a second opinion. A grade that still mentions 1.0 is a config you have not reloaded. Reload only after nginx -t. The Ubuntu page is who runs that process.

Questions we keep getting

Is HSTS enough without pinning?

For a public website, yes, with TLS 1.3 and a cert you replace on ACME. HSTS stops cleartext. It does not stop a rogue public CA. CT and CA distrust are that layer. A mobile app or CLI you release is the remaining place for an SPKI hash.

Should I publish TLSA for my website?

You may, if the zone is DNSSEC-signed and a mail or API client you own will check it. Do not tell a browser user they are safer because the record exists. Chrome will not look.

What about a vendor who requires TLS 1.0?

Do not turn 1.0 back on for the public name. Put that vendor on a separate listener you control, or replace the vendor. The public 443 stays on 1.3.

Irfan Shakeel

Irfan Shakeel / About Author

Cyberย securityย researcher, CEO of Author of 7-Weeks OSINT Program.
CEO & Founder of ehacking.net