You can’t read what’s inside a TLS tunnel. Not without sitting in the middle with your own certificate, and I wasn’t doing that. So for a while I assumed encrypted traffic was a dead end — if I can’t see the payload, what’s left?
Turns out: the shape. And the shape talks.
When someone runs credential stuffing through a proxy, the tunnel stays sealed but the outline of what’s flowing through it is loud. You don’t need the contents. You need the silhouette.
Every failure looks the same
A failed login is about as predictable as it gets. It fails identically. Same error, same JSON shape, same status code, same body length, every single time. The server doesn’t get creative when it says no.
Through TLS, that sameness survives. Encryption scrambles the bytes but not the number of them, so a wall of failed logins turns into a wall of connections with nearly identical bytes_recv. Once I noticed that, I couldn’t un-notice it.
One target API gave me around 1,000 connections in 15 minutes, and the response-size distribution looked like this:
| bytes_recv | count |
|---|---|
| 5042 | 494 |
| 4821 | 148 |
| 4820 | 130 |
| 5041 | 71 |
Over 80% of the responses landed within 200 bytes of each other. That’s not what a person browsing looks like. A human loads different pages with different content at different sizes — the variance is all over the place. This was a tight little cluster, and that tightness ended up being the single strongest signal I had.
Turning “it looks uniform” into a number
Eyeballing a table is fine for one target. I wanted something I could point a script at, so I reached for the coefficient of variation — CV = stdev / mean. It just tells you how spread out the sizes are relative to their average.
A CV below 0.05 means the responses are basically clones of each other, which is exactly what credential stuffing produces. Normal traffic sits above 0.5. That’s a full order of magnitude of daylight between the two, which is a comfortable place to draw a line.
I didn’t want to hang everything on one metric though, so I stacked a few observables into a rough score:
- Response uniformity —
CV < 0.05→ 40 points - Request uniformity —
CV < 0.10→ 15 points - Single JA3 across many connections → 25 points
- High connection rate —
> 1/s→ 15 points - Volume —
> 500 connections→ 15 points
Anything over 30 flags the target. Nothing fancy — the response uniformity does most of the heavy lifting on its own, and the rest is corroboration. Run against my dataset, this cleanly picked out automated stuffing against a major social API, a stock-trading app, several kids’ games, a payments wallet, and a dozen banks. Same score, one dataset — it caught all of them.
JA3 doesn’t tell you if — it tells you how many
The other thing I could see through the tunnel was the JA3 fingerprint, which is basically a hash of how the client negotiated its TLS handshake. And JA3 answers a different question than I expected. It won’t tell you whether you’re under attack, but it will tell you how many tools are behind it.
Two examples sat right next to each other and made the point better than I could.
One trading API took around 1,400 connections with exactly one JA3. One. A single script, one TLS stack, hammering the endpoint over and over without bothering to hide.
A major identity provider’s login endpoint took 64 connections with 60 unique JA3s — someone randomizing their TLS fingerprint on nearly every connection, specifically to dodge the kind of detection I’m describing here.
Both are credential stuffing. The JA3 spread only tells you how much effort the attacker put into blending in, not whether they’re attacking. And the funny thing is, the evasion actually gives them away. Real client populations don’t mint a brand-new TLS stack for every single request. 60 fingerprints across 64 connections is the opposite of blending in — it stands right out. When you work that hard to look random, that’s what gives you away.
Which lands more or less where all of this work lands for me: encryption hides the content, not the shape. And the shape of a machine doing the same thing 1,400 times in a row is a lot more regular — a lot more honest, in its way — than anything a human leaves behind.