Catching bots without a CAPTCHA: the secret of the TLS fingerprint

How to keep fake visitors off your website without a single annoying 'click all the traffic lights' box. A clear explanation of a clever, almost invisible trick.

Share

You know the drill: those irritating boxes where you have to tick 'I'm not a robot', or click every picture with a traffic light in it. That's a CAPTCHA. They exist to keep out automated programs ("bots") that scrape websites, create accounts or spam forms.

The annoying part: CAPTCHAs bother real visitors, and clever bots often get through anyway. In this post I explain a far more elegant approach, one the visitor doesn't even notice. I'll do it in plain language, no technical background required. Further down there's a bit with real code for anyone who wants to see under the hood, but you're welcome to skip it.

Every device has a kind of signature

Imagine that every visitor arriving at your website first shakes hands with the server. That actually happens. It's called a handshake, and it's the moment your browser and the website agree on how to talk to each other securely (that's the padlock in your address bar).

The funny thing is: every type of software does that handshake slightly differently. Chrome does it its own way. Firefox does it differently again. And a homemade bot program? That shakes hands in a completely distinct, recognisable way.

It's a bit like a signature, or the way someone signs their name. You don't need to see their face to know it isn't the same person.

This "handshake signature" has a name: JA3. It's nothing more than a short code that captures exactly how a visitor shakes hands. Two genuine Chrome browsers produce the same code. A little bot running its own software produces a completely different one.

Where most security drops the ball

Plenty of websites already check that signature, but only on the first click, when you open the page. Clever bot builders know this, and have developed tricks to pose as a real Chrome browser on that first click. So they disguise their signature. Nothing new so far.

But here comes the clever part. Many modern websites (think chats, live updates, notifications) open another connection in the background after the page has loaded. Such a permanent line is called a WebSocket. Picture a phone line that stays open so the site can push you things live.

And it's exactly when opening that second line that almost all bots forget their disguise. They shake that second hand with their own, real software again, because the disguise tricks are built for the ordinary connection, not for this background line.

The trick in one sentence

The approach comes down to this:

  1. When someone opens your page, you quietly note their signature (JA3) and tie it to their visit.
  2. When that same visitor later opens the background line (WebSocket), you check the signature again.
  3. Do the two signatures match? Welcome. Do they differ? Door closed.

A real browser uses the same software for both, so the signatures are identical. Automatically. The visitor notices nothing: no box, no traffic lights, nothing.

A bot that was disguised the first time but shows its true colours on the second line is caught instantly. The two signatures don't match, and the connection is refused.

Put another way: you're welcome to knock on the door, but you have to come in with the same hand you signed up with.

Does this actually work?

Yes. To prove it I built a small test setup with three scenarios:

Scenario What happens Outcome Real visitor Same software for page and background line Allowed Stolen session Bot grabs access, but opens the line with its own software Refused Consistent bot Bot uses the same (bot) software everywhere Allowed

The first two are exactly what you want: the real visitor gets in, the sneaky bot is thrown out, without a single CAPTCHA.

The third scenario is there for honesty's sake. A bot that neatly uses the same software everywhere does get in with this one trick. That's because this check proves the same visitor is on both ends of the line, not that it's necessarily a human.

For the curious: what does this look like in code?

Not in the mood for code? Feel free to skip this block, the rest reads on fine. For those who do want a look: below is the heart of my proof-of-concept, in plain Python with no external packages.

First: such a "signature" really is just text. You stick a few properties of the handshake together and turn that into a short code (an MD5 hash). Two identical browsers produce literally the same string:

771,4865-4867-4866-49195-49199-...-156-157-47-53,0-5-10-11-13-...-65281,4588-29-23-24-25-256-257,0
     │            │                                   │
     version      list of cipher suites               list of extensions ...

→ MD5:  346e39f02e92c9e5e1bafdd34ec3bce5

The hard part is that you have to read that signature before the secure tunnel snaps shut. The standard tools only hand you the connection after the handshake, and by then those bytes are gone. The trick is to peek briefly at the very first message on the line, without "eating" it, so the normal security can still go over it afterwards:

def peek_client_hello(sock, timeout=5.0):
    """Read the first TLS message with MSG_PEEK: look, don't consume."""
    sock.settimeout(timeout)
    while True:
        head = sock.recv(5, socket.MSG_PEEK)   # peek, cursor stays put
        if len(head) < 5:
            continue
        record_len = struct.unpack("!H", head[3:5])[0]
        need = 5 + record_len
        buf = sock.recv(need, socket.MSG_PEEK)
        if len(buf) >= need:
            return buf[:need]                  # the raw handshake, intact

And then the heart of the whole trick. On the ordinary page you give the visitor a session and remember which signature goes with it. As soon as that same visitor opens the background line, you simply compare the two:

# On the page: tie the session to this visit's signature.
self.sessions[session_id] = ja3

# On the WebSocket: read the same signature again and compare.
if ja3 != self.sessions.get(session_id):
    send_http(conn, "403 Forbidden", "tls fingerprint mismatch")
    return   # different hand than you signed up with → door closed

That's it. No artificial intelligence, no black box: three lines of comparison that filter out a bot the moment it drops its disguise.

One lock is never enough

That's why this trick is meant as one layer in a bigger whole. Good security works like an onion: layer upon layer. Other layers you can stack on top:

  • A list of known, real browsers. Only accept signatures that match what real browsers currently use.
  • Compare the story with the signature. A visitor claiming to be Chrome, but whose signature fits something completely different? Suspicious.
  • A small computation in the background. Unnoticeable for one visitor, but too expensive for someone wanting to fake thousands of visits at once.
  • Checking whether the whole page really loaded, images, styling and all. Bots often skip that.

No single layer is unbreakable on its own. But together they make getting in so annoying that most bad actors simply give up and try their luck elsewhere. And that's exactly the goal.

In short

  • Every piece of software has a recognisable "signature" when setting up a secure connection.
  • Most security checks it only once; bots cleverly disguise themselves around that.
  • By checking the signature a second time, on a background line that bots often forget to disguise, you catch plenty of bots, completely invisibly to real visitors.
  • It's no silver bullet, but as one layer in solid security it's surprisingly effective and irritation-free.

This is a defensive technique: meant to protect your own website, forms or services. It was tested in a closed test environment, not aimed at anyone.

Tags

TLS fingerprinting, JA3, bot detection, WebSocket security, anti-scraping, CAPTCHA alternative, website security, Python, cybersecurity

Get in touch