bots

Bot Protection Without CAPTCHA: How It Actually Works

Block bots without ever showing a human a puzzle. The machinery: TLS fingerprints, header order, Client Hints, and a silent browser challenge.

Jun 10, 2026 · 5 min read

You can stop bots without ever asking a human to find the traffic lights. No grids, no warped text, no "I am not a robot" box. The puzzle was always a confession that we did not know who was on the other end of the socket — so we made the human prove it for us. Modern detection stopped asking the human anything. It asks the connection and the browser instead, and those two cannot lie nearly as well as a person can click bicycles.

Here is the machinery that makes that possible.

Traditional CAPTCHAs and their problems

A CAPTCHA is a tax we all agreed to stop questioning. Every challenge you show costs you conversions — a measurable slice of real customers who hit the puzzle, sigh, and leave. It punishes the people you want most: the ones in a hurry, the ones on a flaky connection, the ones using a screen reader for whom an image grid is a wall.

That would be a tolerable trade if it worked. It does not. Solving farms clear puzzles for a fraction of a cent each, and the same machine-learning that lets your phone read a license plate lets a script read distorted text faster than you can. The economics are upside down: you pay in friction and lost revenue, the attacker pays a rounding error, and the bot walks through the front door holding a token that says "definitely human." A challenge only filters the attackers too lazy to pay the toll. The ones you actually worry about budgeted for it.

So if asking the human is both costly and ineffective, stop asking the human — and ask everything around them instead. Every request arrives wrapped in layers: a TLS handshake, an HTTP/2 connection, a stack of headers, and, if it is a real browser, a JavaScript runtime you can quietly interrogate. None of those layers were built to deceive you, and most automation tooling is sloppy about at least one of them. Passive signal collection is the whole game — gather what the client volunteers across every layer, then check whether the layers agree with each other.

They usually do not.

What the connection reveals (TLS & HTTP/2 fingerprinting)

Before a single byte of HTTP is exchanged, the client sends a TLS ClientHello, and that message is a fingerprint. It lists the cipher suites in a specific order, the extensions in a specific order, the supported groups and elliptic curves, the EC point formats, the signature algorithms, and the ALPN protocols on offer. None of that is random per request. A given client library, on a given platform, produces a stable shape. Hash that shape and you get a fingerprint you can match against known good and known bad clients — this is the JA3 and JA4 family of techniques, an open approach, not anyone's proprietary box.

Real Chrome does something most scripting libraries forget: it sprinkles GREASE values into the ClientHello. Those are deliberately reserved, randomized placeholders defined by the spec so that middleboxes do not ossify around a fixed set of values. A genuine browser sends them. Most HTTP clients and scripting stacks do not bother. Their absence is a tell all by itself.

HTTP/2 carries its own signature one layer up. The SETTINGS frame the client sends at connection start has telltale values — header table size, initial window size, max concurrent streams. The pseudo-headers come in a particular order; a real browser sends :method :authority :scheme :path, and a library will often order them differently. Window-update sizes and the presence and shape of PRIORITY frames differ too. A browser and a requests call do not negotiate HTTP/2 the same way, and the difference is legible.

The killer move is the cross-layer mismatch. A request shows up with a User-Agent claiming "Chrome 120 on Windows," but the TLS fingerprint matches Go's crypto/tls, or Python, or curl. The User-Agent is a string the client types out; anyone can forge it in one line. The TLS stack is the actual code making the handshake, and you cannot fake it without re-implementing a browser's network layer. So when the story the headers tell disagrees with the story the handshake tells, you do not have a hard call to make.

What the headers reveal

Drop down to HTTP and the request itself is dense with signal. Here is a real header block from a current Chrome on Windows:

http
GET /checkout HTTP/2 Host: shop.example.com Sec-Ch-Ua: "Chromium";v="120", "Not(A:Brand";v="24", "Google Chrome";v="120" Sec-Ch-Ua-Mobile: ?0 Sec-Ch-Ua-Platform: "Windows" Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8 Sec-Fetch-Site: same-origin Sec-Fetch-Mode: navigate Sec-Fetch-User: ?1 Sec-Fetch-Dest: document Accept-Encoding: gzip, deflate, br, zstd Accept-Language: en-US,en;q=0.9

Start with that User-Agent, because almost all of it is historical fiction. Mozilla/5.0 — every browser claims to be Mozilla, a fossil from the 1990s browser wars. AppleWebKit/537.36 (KHTML, like Gecko) — Chrome is not WebKit anymore and never was Gecko, but it swears it is "like" both so old sniffing code keeps working. Safari/537.36 on a Windows machine with no Safari installed. The only honest tokens are Windows NT 10.0; Win64; x64 and Chrome/120.0.0.0, and even those are trivially editable. Treat the UA string as a costume, not an ID.

The real signal is structural. Browsers emit their headers in a stable, known order, and that order is itself a fingerprint — Chrome's sequence differs from Firefox's, and both differ from whatever order a library produces, which is frequently alphabetized or grouped by the code that built the request. You can fingerprint a client purely by the order and casing of its headers, before you read a single value.

Then there is the metadata a real browser attaches without being asked. The Sec-Fetch-* family — Sec-Fetch-Site, Sec-Fetch-Mode, Sec-Fetch-Dest, Sec-Fetch-User — describes the context of the request: was it a top-level navigation, an image load, a fetch from same-origin script. The browser fills these in automatically. Most bots forget them entirely. The Accept headers carry signal too: a real Chrome advertises br and zstd in Accept-Encoding and a specific, ordered Accept string for documents. A naive client sends Accept: */* and calls it a day.

Contrast all of that with what a forged or lazy request looks like. At the bottom of the barrel:

http
GET /checkout HTTP/1.1 Host: shop.example.com User-Agent: python-requests/2.31.0 Accept-Encoding: gzip, deflate Accept: */*

No Sec-Fetch headers. No Client Hints. No brotli. HTTP/1.1 against a server every browser would have spoken HTTP/2 to. It is not pretending to be anything — it is just a script. The more dangerous case is the middle: a request that copies the full Chrome UA but ships none of the Sec-Fetch or Sec-CH headers that real Chrome always sends alongside it. That inconsistency is louder than the honest python-requests line, because it is a lie with a gap in it.

Client Hints — what the browser volunteers

User-Agent Client Hints are the structured replacement for that crusty UA string, and they hand you a fresh consistency check for free. The server advertises what it wants with Accept-CH (and Critical-CH for the values it needs on the very next request), and a compliant browser answers with Sec-CH-UA, Sec-CH-UA-Mobile, and Sec-CH-UA-Platform by default. Ask for more and the browser returns high-entropy hints: Sec-CH-UA-Full-Version-List, Sec-CH-UA-Arch, Sec-CH-UA-Bitness, Sec-CH-UA-Model.

The value is not any single header — it is the triangulation. You now have three independent descriptions of the same client: the legacy UA string, the Client Hints, and, once JavaScript runs, navigator.userAgentData. A real browser keeps all three in perfect agreement because they come from one source. A forger almost never does. They patch the UA string to say Chrome 120 and forget the Client Hints, or they spoof navigator.userAgentData in JS but leave the HTTP-level Sec-CH-UA-Platform saying something else. Three stories that should be identical, and a seam where they disagree.

The silent challenge in the browser

This is the heart of "captcha-free." When the client is a real browser, you can run a small JavaScript challenge in the background — no box to check, no image to click, the user never sees it — and ask the runtime a few hundred questions that an automation framework answers wrong. It executes in milliseconds while the page loads, and it is the richest source of truth in the whole stack.

The cheapest tells are the ones the automation tools leak by default. Under WebDriver, navigator.webdriver is true — the spec literally requires browsers to advertise that they are being driven. Headless builds historically put HeadlessChrome right in the UA string. A driven or stripped-down browser often has an empty navigator.plugins and navigator.mimeTypes, an empty navigator.languages, and a misshapen or missing window.chrome object. None of these is proof on its own. Together they sketch a clear picture.

Then there is framework leakage — globals that specific tools inject and forget to clean up:

js
const tells = [ navigator.webdriver === true, 'cdc_adoQpoasnfa76pfcZLmcfl_Array' in document, // ChromeDriver '__selenium_unwrapped' in document, '_phantom' in window, '__nightmare' in window, 'domAutomationController' in window, ]; const suspicious = tells.filter(Boolean).length;

Each of those is a fingerprint of a real, named automation stack, dropped into the page like muddy boot prints. A clean human browser has none of them.

A sharper attacker patches those globals away — so you check whether the patch itself left a mark. Call Function.prototype.toString on a built-in like navigator.permissions.query and a genuine native function reports [native code]. Evasion tools that monkeypatch a function to hide their presence frequently break that invariant, returning JavaScript source or the wrong string. The act of hiding becomes the thing you detect.

Rendering is its own deep well. Draw text and shapes to a hidden canvas, then hash the pixels: the result depends on the GPU, drivers, OS font rasterizer, and anti-aliasing, and it is stable per machine but varies across machines. Query WebGL for UNMASKED_RENDERER_WEBGL and a real laptop names its actual GPU; a headless box in a datacenter names a software rasterizer — Google SwiftShader, or llvmpipe (LLVM ... / Mesa). There is no consumer laptop on earth rendering through SwiftShader. That string is a confession that you are talking to a VM. AudioContext fingerprinting and font enumeration pile on more entropy in the same vein.

Best of all are the physics checks — does this machine make sense? The platform in the UA should match the OS implied by the WebGL renderer string. hardwareConcurrency and deviceMemory should look like a laptop, not a 96-core server. And the classic headless contradiction: Notification.permission reads "denied" while navigator.permissions.query({name: 'notifications'}) resolves to "default". A real browser keeps those consistent. Headless Chrome, for years, did not. A real device is internally coherent in a thousand small ways, and synthetic environments fall apart under cross-examination.

You can also make automation expensive without detecting it at all. Hand the client a small proof-of-work — a hash puzzle that takes a few milliseconds to solve once. One real visitor never notices. An attacker firing a million requests now has to burn a million CPU-seconds, and the spreadsheet that made the attack profitable stops balancing. You are not proving the client is human; you are pricing the abuse out of the market.

Behaviour over time

Everything above is a snapshot. The deepest signal is what happens across thousands of requests, because that is where an attacker's scale betrays them. Real human fingerprints are diverse — entropy is spread across the population. So when the same "unique" canvas hash and TLS fingerprint show up behind ten thousand different residential IPs, you are not looking at ten thousand people. You are looking at one automation image, wearing a different proxy each time.

Behaviour fills in the rest. Mouse paths, pointer events, scroll dynamics, and keystroke timing have a human texture — jitter, acceleration, pauses, correction — that scripts approximate badly or skip entirely. Request cadence and velocity matter: humans browse in bursts with think-time between actions; bots tend toward metronomic timing or impossible speed. One request is a snapshot. A thousand requests is a confession.

Why this beats a puzzle

Add it up and the advantages are not subtle. The human is never interrupted, so you stop paying the conversion tax that made CAPTCHAs worth questioning in the first place. The signals are cross-checked across layers, so forging one is not enough — patch the UA and the TLS handshake still disagrees, spoof the handshake and the JS challenge still catches the SwiftShader renderer, fix that and the population-level entropy still flags ten thousand identical clients. Defense in depth, but for identity. And the decision happens server-side, at the edge, in single-digit milliseconds, before the request ever reaches your origin. An attacker has to win every layer at once. You only have to catch them on one.

This is exactly what Vinishu does. It reads the TLS and HTTP/2 fingerprints, checks header order and Client Hints for consistency, runs a silent in-browser challenge, and scores behaviour across the population — all of it combined into one verdict at the edge, with no puzzle and no user-visible friction. It sits in front of your origin as a reverse proxy, which is why the decision can happen before your application sees a thing.

< 8 ms
Decision at the edge
6 layers
TLS, HTTP/2, headers, hints, JS, behaviour
0
CAPTCHAs shown to humans

Key takeaways

  • A CAPTCHA outsources identity to the human, and the trade is bad on both ends: friction for real users, a rounding error for attackers who pay a farm to solve it.
  • The connection cannot lie the way a person can — TLS and HTTP/2 fingerprints, header order, and Client Hints expose a forged client through cross-layer mismatch.
  • A silent JavaScript challenge interrogates the browser with zero interaction: automation globals, native-function tampering, canvas and WebGL renderer strings, and physics checks that synthetic environments fail.
  • Behaviour across the population is the closer — identical fingerprints behind thousands of IPs and inhuman timing are things no proxy network can hide.

This is the captcha-free approach to bot protection Vinishu was built on. If you are tired of taxing your real users to inconvenience the bots, talk to us.

Learn

proxies