TLS Hides Your Data, Not Your Behaviour: What Traffic Analysis Can Still See
Sit on a coffee shop's wifi with Wireshark open and watch someone else's laptop talk to the internet. You cannot read a single byte of their HTTPS traffic. You can, however, watch the TLS handshake go past in the clear, see the server name they are connecting to, count the packets, measure their sizes and time the gaps between them. Do that for a few minutes and you will often know which website they are on, roughly what they are doing on it, and sometimes even how long the password was that they just typed into an SSH session two tables over. None of this requires breaking TLS. It requires not needing to.
This is traffic analysis: inferring content from the shape of a conversation rather than its words. TLS was designed to stop the first kind of eavesdropping. It was never designed to stop the second, and for a long time nobody particularly worried about it because the shape of traffic seemed too coarse to matter. That turned out to be wrong.
What is actually still in the clear
A TLS 1.3 connection encrypts the HTTP request and response, headers included. But several things sit outside that envelope, either by protocol necessity or by common deployment choice:
- The destination IP address, always, because routers need it to deliver packets.
- The Server Name Indication (SNI) field in the ClientHello, sent unencrypted so the server knows which certificate to present before the handshake has established any shared secret. If you connect to a shared-hosting edge like Cloudflare's, the SNI is frequently the only way anyone can tell which of the thousands of sites behind that IP you actually wanted.
- The TLS version, cipher suites offered and extension list, which together form a fingerprint (commonly called JA3 or JA4) that identifies the client software, sometimes down to the exact library and version, regardless of what the user agent header claims.
- Packet sizes, direction and timing. TLS records get padded to a small degree in TLS 1.3, but not enough to erase the underlying shape of a request-response exchange.
Individually these look like scraps. Together they are a surprisingly rich side channel, because the shape of a network conversation correlates with what generated it far more than intuition suggests.
The shape of a page load is a fingerprint
Load a specific Wikipedia article and your browser fetches the HTML, then a fairly deterministic set of images, stylesheets and scripts, each with a size that varies little between requests. The sequence of packet sizes and directions produced by that page load is close to a fingerprint of the page itself, even though every byte was encrypted end to end.
This is the basis of website fingerprinting attacks, a research area that has been particularly active around Tor, precisely because Tor hides destination IPs and SNI but cannot hide packet timing and size without paying a serious performance cost. Given a closed set of candidate sites, a classifier trained on packet-size sequences can identify which one a user visited with accuracy good enough to be a genuine deanonymisation risk, not just an academic curiosity. The open-world case, where the visited site might be anything, is harder, but not nearly as hard as you would want it to be if you were relying on encryption alone for protection. You do not need machine learning to get a feel for why this works. Here is the idea in miniature: bucket outgoing and incoming packet sizes from a capture into a small feature vector, and compare it against known fingerprints.
package main
import "fmt"
// bucket assigns a packet to a coarse size band, direction-signed.
// Positive length means outbound, negative means inbound.
func bucket(length int) int {
switch {
case length < 100:
return 0
case length < 500:
return 1
case length < 1200:
return 2
default:
return 3
}
}
// fingerprint turns a sequence of signed packet lengths into a
// histogram of (direction, size-band) counts, a crude but real
// feature vector used as a first step in traffic classification.
func fingerprint(packets []int) map[[2]int]int {
counts := make(map[[2]int]int)
for _, p := range packets {
dir := 0
if p < 0 {
dir = 1
p = -p
}
key := [2]int{dir, bucket(p)}
counts[key]++
}
return counts
}
func main() {
capture := []int{517, -1400, -1400, 220, -890, 517, -1400}
fmt.Println(fingerprint(capture))
}
Real fingerprinting classifiers use richer features (ordering, timing gaps, cumulative sums) and proper machine learning rather than a histogram, but the principle is the same one your eye would notice on a packet size graph: distinct pages have distinct shapes, and shapes survive encryption.
Video without the sound
Adaptive bitrate streaming makes this worse in an interesting way. Netflix, YouTube and similar services fetch video in short segments, each encoded at a bitrate chosen by the player based on measured throughput. The sequence of segment sizes over the course of a stream is closely tied to the actual content, because a scene with a lot of motion needs more bits than a static shot. Researchers have shown that this segment-size sequence is often distinctive enough to identify which specific title, and even which point in it, someone is watching, purely from encrypted traffic metadata, without decrypting anything or ever seeing the video. Nobody typed that into a form. It leaked out of the compressor's own reaction to the content, via the wrapper of packet sizes TLS could not smooth away.
DNS: the leak before the leak
Before any of this happens, most connections start with a DNS lookup, and plain DNS is unencrypted UDP. Even if SNI were perfectly hidden, an observer sitting anywhere between you and your resolver sees exactly which hostname you resolved, seconds before you connect to it. DNS-over-HTTPS and DNS-over-TLS close that specific leak, but they introduce their own traffic-analysis surface: DoH queries have their own size and timing patterns, and a handful of studies have shown that individual DoH resolvers, and sometimes individual query types, can be fingerprinted from the encrypted DoH stream itself. Moving a leak behind TLS reduces it. It does not automatically eliminate it.
Encrypted Client Hello closes SNI, eventually
The direct fix for SNI leakage is Encrypted Client Hello (ECH), a TLS extension that wraps the real ClientHello, including the SNI, inside an outer, encrypted one, using a public key fetched via DNS beforehand. Where it is deployed, an observer sees only that a connection was made to a shared front-end IP address, not which of the sites behind it was requested. Cloudflare has offered ECH support for some time and both Firefox and Chromium-based browsers have shipped support in various forms, but adoption depends on the server operator publishing the right DNS records and on the client and server agreeing on the mechanism, so plenty of ordinary web traffic still sends SNI in the clear. It is also worth being honest about what ECH does not fix: it hides which of several co-hosted sites you visited, but the destination IP is still visible, and if a site is not behind shared infrastructure, the IP alone tells the observer as much as the SNI would have.
QUIC changed the wrapper, not the physics
HTTP/3 over QUIC encrypts more of the transport header than TCP-based TLS ever could, including things like sequence numbers that used to be visible in the clear. That is a genuine improvement. But QUIC packets still have observable sizes, directions and timing, and the connection ID used to route packets during a migration is itself a fixed value that persists across a session unless it is rotated deliberately, which makes it a decent tracking token if an implementation does not rotate it. Moving to a newer transport tends to close specific leaks rather than the underlying class of attack, because packet-level metadata is a property of moving bits over a network, not of any particular protocol's design mistakes.
Padding helps, mostly at a cost
The honest defence against traffic analysis is to make the shape of your traffic boring: pad packets to fixed sizes, insert chaff traffic during idle periods, and normalise timing so that a request for a tiny API response looks the same on the wire as a request for a large one. Tor does some of this with fixed-size cells. Some VPN and messaging protocols pad to a small set of bucket sizes for exactly this reason. It works, but it is not free: padding wastes bandwidth, and normalising timing adds latency, so most mainstream protocols do it partially or not at all, because the people deploying them are optimising for speed and cost rather than resistance to a statistical classifier nobody using the service will ever hear about.
Which is really the point. TLS was a genuinely enormous improvement in what an on-path observer, an ISP, a coffee shop, a state-level network operator, can extract from your traffic. It just was never the whole answer, and metadata retained under something like the UK's data retention framework was never "just metadata" in the reassuring sense that phrase implies. If the shape of a connection can tell you which Wikipedia article someone read or which episode they watched, the shape of a connection was never really separate from its content. It was just content in a format that took a bit more work to read.