Almost everyone who has ever built something on the web has, at some point, thought about this from one side or the other. Maybe you tried to squeeze a second free trial out of a service, or maybe you were the one running the service and woke up to ten thousand accounts that all signed up in the same minute. Either way you brushed up against the same machine, and from the outside that machine looks like a single wall: a captcha, a "we detected unusual activity" banner, a flag that goes up or stays down.
It is not a wall, and it is not a flag. It is an onion. Layer on top of layer on top of layer, and each of those layers has an almost endless amount of smaller layers hiding inside of it. There is never a single check that decides "bot" or "not bot", and there is never a point where you have added enough checks. The only real limit is your creativity.
This is a walk from the very top of that onion all the way down to the bottom. We will define what we are actually defending against, look at the goal, and then go layer by layer, scratching the surface of each one and then digging into the more niche and interesting details underneath. By the end you should not see a wall anymore. You should see the onion.
Before anything else, two words get thrown around as if they mean the same thing, and untangling them makes everything after it clearer: and .
A bot is a means. It is automation, a script, something that is not a human hand on a mouse. Fraud is an intent. It is the abuse of a service or feature for some gain that the service never meant to give away.
Four sessions, sorted by what drove them and what they were for
Driven by automation
Driven by a human hand
Fine
automationGooglebot reading the same product page every six hours.A bot, and nothing to answer
a human handA customer paying on a phone, one thumb, one card.Neither
Abusive
automationA script minting 4,212 accounts overnight to farm follows.Both at once
a human handOne person, fifty virtual cards, fifty signup bonuses.Fraud, with no bot in it
Two of these are bots. Two of these are fraud. They are not the same two.
The two are not the same and they do not always travel together. Googlebot is a bot, and it is not fraud, it is welcome. A real human sitting at a real laptop, opening fifty accounts by hand with fifty virtual bank cards to farm a signup bonus, is fraud, and there is not a single bot involved. Most of the interesting cases live somewhere in the middle, but keeping the two axes separate is what lets us understand why the defense is built the way it is. Detecting a bot is a technical problem: is this thing automated? Detecting fraud is a context problem: is this action, by this identity, abusive? Different layers of the onion answer different halves of that question, and the strongest systems are the ones that combine both.
In order to understand the defense for something, we must first understand and define what we are defending against.
Fraud comes in many different forms. Here we will focus on fraud on the web, of which there are two main variations: automated and manual. They overlap a lot, the line between them is quite foggy, and in a lot of cases one assists the other.
Manual
Manual fraud on the web is most frequently the abuse of a certain service or feature for some gain. It shows up as credit card fraud, as promo abuse (where one individual creates many accounts in order to keep using a promo, like a free trial meant for new accounts only), as artificial votes and likes on social media, as spam, and much more.
When someone's goal is to abuse a promotion, for example to listen to music without ads, there is little need for them to automate anything. The promo usually lasts a month per account (the promo here being free premium), so the whole "attack" is: make an account, claim the month, and come back in thirty days. The service might deploy countermeasures like device fingerprinting and IP based limiting to combat this, but both can be bypassed quite easily, fingerprinting with anti-detect browsers that spoof your fingerprint, and IP limits with that make you look like a totally new individual on a totally new connection.
Most people think the countermeasures stop there. In reality, in the world of fraud detection, and especially when the fraud is done at low volume, you have to go much further. Fraud detection is not one layer, and it is not a one-solution-fits-all problem. It is an onion, layers on top of layers of different checks and algorithms, each targeting a different signal.
So in our promo example, once fingerprinting and IP limiting have been bypassed, we simply move to the next layer. We look at the email that was used to register: is it brand new? Which social platforms is it registered on? Does it have any history at all? Then we look at the payment method used to claim the promotion: is it a virtual card? Is it issued by one of the popular internet banks like Wise, Bunq or Revolut that fraudsters lean on because they hand out virtual cards freely? Each of those questions is a new datapoint, and together they form a whole new layer sitting on top of the technical one.
Notice what happened there. We stopped looking at the device and the session, and started looking at the identity. This is the more "fraud" side of fraud detection, checking the actual personal information provided rather than the machine that provided it. The device layer could not catch this person, but the identity layer might.
Automated
Automated fraud is most frequently used for high-volume tasks: mass liking a post, scraping, spamming, and so on. Automated fraud inherits from manual fraud and then branches out. If someone wanted to sell ready-made accounts with a promotion already claimed, they would have to automate the manual version so it can run at scale.
Against automated fraud we can deploy all the same defenses we use against manual fraud, but the target is usually different, so we often do not have the same information to lean on. There may be no real payment method and no aged email to inspect. In those cases we have to rely more heavily on the technical signals and on statistical analysis across many sessions at once.
Take a high-volume attack that mass-creates accounts to like posts or follow accounts. We can look at the exact behavioural pattern of how each session goes from account creation to the end action (a like, a follow). One session on its own might look perfectly legitimate. But take all of the recent sessions, build a fingerprint out of the actions rather than the device, and patterns start to emerge. The individual scripts driving all those seemingly legitimate sessions reveal themselves, because they all move the same way. One session is noise. Ten thousand sessions that all move identically are a signature.
Now that we know what we are dealing with, we can define a goal. And the goal is not to block and stop every single fraud attempt. It never is.
The goal is to make it more expensive to carry out the fraud than the fraud is actually worth. In other words, push the attacker's cost up until their return on investment turns negative and the whole thing stops being viable for them.
This goal is not reached with one solution or one clever vector. It is reached by stacking layers on top of layers on top of layers, building the detection onion until getting through all of it costs more time, money and effort than the payoff on the other side.
In the most primitive system this could be as simple as adding an extra step before the targeted action, a required SMS confirmation, a required email confirmation, a minimum deposit. Those all raise the attacker's cost. But this primitive solution has two problems. It is a single layer, so it can be bypassed (it just requires more resources), and, more importantly, it taxes every legitimate user too.
Every check is bought out of two budgets at oncewhat it costs the attackerwhat it costs the honest user
- Email confirmation
A catch-all domain they already own
Leave the tab, find the mail, come back
- SMS confirmation
A fresh number per cycle, bought in bulk
Hand over a phone number, or leave
- Proof of work at a fixed difficulty
Port the routine to native code, run it on a GPU
Milliseconds on a laptop, a stutter on a cheap phone
- Proof of work scaled to the score
The tax lands on every cycle that looks automated
Nothing, because a clean session never trips it
Spend theirs, not yoursThe layer worth keeping is the one the honest session never notices, because it never trips it.
That second point is the other half of the goal, and it is the half people forget. You are not just spending the attacker's money, you are spending your own users' patience. Every check you add has a friction cost, and there is a budget for that friction. An SMS gate stops a lot of promo abuse, but it also loses you real signups who did not feel like handing over their phone number. This is exactly why nobody just cranks every check to maximum sensitivity: a detection that is too aggressive fails its real job, which is not "catch fraud" but "catch fraud without driving away the humans". The art is spending as much of the attacker's ROI as you can while spending as little of your users' friction budget as possible. A good layer costs the attacker a lot and the legitimate user nothing, because the legitimate user simply never trips it.
So that is the frame for everything below. We are going to design each layer of the onion, scratch the surface of each, and dig into the interesting parts underneath. And for every layer we should be asking the same two questions: how much does this cost the attacker, and how much does it cost the honest user?
Before we start peeling, it helps to know roughly what order we are peeling in, because the layers are not random. Most of the technical ones follow the attacker's own stack, from the most browser to the least browser: a real browser that is being automated, then a real browser whose fingerprint is being spoofed, then eventually no browser at all, just a script talking to the API directly. Running alongside those, not underneath them, are the layers that do not care about the browser at all: the network the request came in on, and the identity behind it. We will walk down the technical stack first, then step out to the network and the human, and finally talk about how all of it gets combined, because a layer on its own is almost worthless, and the whole point of the onion is what happens when you stack it.
Six layers, and the single thing that beats each one on its own
The attacker's own stack, most browser to least
- 01Browser flagsThe properties automation leaves lying in the pageA stealth driver that sets them all back
- 02Anti-detect enginesWhether the fingerprint describes a machine that shippedA render replayed from a real donor machine
- 03Engine integrityWhether a browser ran the code at allA sandbox that answers every probe correctly
Alongside it, not underneath it
- 04The networkThe address, the TLS handshake, the TCP stack under itA residential proxy on a real home connection
- 05Behavioural biometricsThe curve of the mouse and the rhythm of the keysA patient human doing it by hand
- 06Business logicThe order of the actions, across every account at onceOne account, moving slowly, on its own
Every row in this figure loses alone. None of them has to win alone.
The first and simplest technical layer is browser flags: the little tells that the browser engine itself hands you for free. These show up as objects and properties in the environment that only exist when the browser is being driven by code instead of a human. The classic one is navigator.webdriver, which is true when the browser was launched under automation. Then there are the leftovers that specific tools smear across the page, the cdc_-prefixed globals that ChromeDriver injects (cdc_adoQpoasnfa76pfcZLmcfl_Array, $cdc_asdjflasutopfhvcZLmcfl_), the _Selenium_IDE_Recorder and __selenium_evaluate hooks Selenium leaves behind, __webdriver_evaluate, __driver_evaluate, __nightmare, __phantomas, and a long tail of framework-specific fingerprints going all the way back to PhantomJS and Watir.
A real sensor does not check one of these, it checks all of them at once and packs the result into a single number. Conceptually it is just a big bitmask:
const automationBits =
Boolean(window.__nightmare) |
(Boolean(window.cdc_adoQpoasnfa76pfcZLmcfl_Array) << 1) |
(Boolean(window._Selenium_IDE_Recorder) << 2) |
(Boolean(window.__webdriver_evaluate) << 3) |
(Boolean(window.__driver_evaluate) << 4) |
(Boolean(window.domAutomationController) << 5) |
(Boolean(document.$cdc_asdjflasutopfhvcZLmcfl_) << 6);
A bitmask is a way of answering twenty yes-or-no questions with one number. Each answer gets its own position in the number, so a single integer arriving at the server carries every check at once, and the server can read off which ones tripped. It travels smaller than a list and it gives an attacker reading the payload nothing obvious to grep for.
It will also read the same tell in more than one way and expect the answers to agree. There is a webdriver attribute on the document element, a webdriver property on navigator, a webdriver on window, the presence of XPathResult, a driver attribute, a selenium attribute, and it collects every one of them into a row:
function collectSeleniumData() {
const cdc =
window.$cdc_asdjflasutopfhvcZLmcfl_ || document.$cdc_asdjflasutopfhvcZLmcfl_ ? "1" : "0";
const wdAttr = document.documentElement.getAttribute("webdriver") != null ? "1" : "0";
const navWd = navigator.webdriver ? "1" : "0";
const winWd = typeof window.webdriver != "undefined" ? "1" : "0";
const xpath = typeof window.XPathResult !== "undefined" ? "1" : "0";
const driverAttr = document.documentElement.getAttribute("driver") != null ? "1" : "0";
const seleniumAttr = document.documentElement.getAttribute("selenium") != null ? "1" : "0";
return [cdc, wdAttr, navWd, winWd, xpath, driverAttr, seleniumAttr].join(",");
}
Now, these flags are perfect right up until the attacker switches to a . Stealth drivers exist to hide every one of these tells: they do not add the cdc_ globals, they set navigator.webdriver back to false, they patch out the attributes, and they wire in libraries to generate human-like mouse movement instead of teleporting the cursor. Against a properly configured stealth setup, the entire bitmask above reads clean.
But stealth drivers are not perfect either, and here is the first place we find a layer inside a layer. To spoof a value, the tool has to intervene somewhere, and that intervention is itself detectable if you know where to look.
The cheapest example is timing. In a stock build of Chromium, or any other engine, reading two different navigator properties runs roughly the same amount of code and takes roughly the same amount of time. Compare navigator.cookieEnabled and navigator.webdriver: both are simple property reads, both should cost about the same. The way a lot of stealth tools hide webdriver is by redefining the getter in JavaScript:
Object.defineProperty(Navigator.prototype, "webdriver", {
get() {
return false;
},
});
That replacement getter is extra JavaScript sitting in front of the value. Reading cookieEnabled goes straight down the native path; reading webdriver now detours through an injected function first. A single read is far too noisy to measure, but average a hundred thousand of them and the detour shows up:
function averageReadNs(read, iterations) {
const start = performance.now();
for (let i = 0; i < iterations; i++) read();
return ((performance.now() - start) / iterations) * 1e6;
}
const clean = averageReadNs(() => navigator.cookieEnabled, 200000);
const patched = averageReadNs(() => navigator.webdriver, 200000);
One read of either property takes a few nanoseconds, and the clock available to a web page is far too coarse to see a difference that small. Do it two hundred thousand times and the measurement error averages out while the extra work does not, so a difference that was invisible in one read becomes a stable gap you can rely on.
If patched sits consistently above clean by a stable margin, you are almost certainly looking at a JavaScript-level hook, not a real browser.
The important caveat, and the reason the next layer even exists, is that this timing trick only catches spoofing done in JavaScript. Tools that patch at the source level, or that simply never set the automation flag in the first place, return false with no detour and no hook, so the timing is identical and this particular check sees nothing. That is exactly the gap that anti-detect engines are built to live in.
One more thing worth internalising before we move on. When you actually watch a real commercial sensor run, it is not doing this once at startup. Instrument the environment and you will see something like navigator.userAgent read over a thousand times, Math.random called nine hundred times, document.documentElement touched nearly a thousand times, navigator.webdriver probed a dozen times, and one particular internal function passed to Function.prototype.toString thirty-something times in a row. That last one is not curiosity, it is an integrity check running on a loop, constantly re-verifying that its own critical function has not been swapped out from under it. The sensor assumes it is under attack and keeps checking its own reflection. That mindset is the whole game.
Anti-detect engines are the engines behind anti-detect browsers (the Mimic engine inside Multilogin is the canonical example). Their purpose is to look like a fully legitimate, stock build of Chrome while carrying extra machinery that spoofs the values and functions used to fingerprint the environment.
They do a whole spread of things: spoof the user agent, the reported CPU and GPU, the memory, add noise to canvas and WebGL renders, swap the font list, and much more. The goal is to let one physical computer launch many browser instances where each one looks, to the site, like a completely separate and completely legitimate machine.
These engines are genuinely good at hiding the fact that they are not stock Chrome. But with every value they spoof, they expose more surface, and more surface means more room to leak. And here is the beauty of it again: there are multiple layers inside this layer. There is never a binary "detected / not detected" end state, only how far your creativity is willing to take you.
Let's start at the surface. For an engine to spoof a fingerprint, it first has to decide what the spoofed fingerprint will be. It needs to generate a combination of all the fingerprint values, and that generation is itself a fingerprint. Most of these engines carry finite lists, a set of CPUs, a set of GPUs, a set of memory sizes, a set of screen resolutions, and they roll the dice to pick a combination and apply it. But the lists are finite, and, more usefully, a lot of the combinations they can produce do not exist in the real world.
One profile the engine rolled, read back field by field
navigator.platformLinux armv8lPhones and tablets
hardwareConcurrency8Phones, laptops, towers
deviceMemory2Budget phones
WebGL rendererANGLE (NVIDIA, GeForce RTX 4080, Direct3D11)Windows desktops, 16 GB and up
screen3840 x 2160Desktop monitors
font listSegoe UI, Calibri, Cambria MathWindows, since 2007
No factory built thisA Direct3D desktop GPU and a Windows font list, on an ARM platform with 2 GB of memory. Every value is real somewhere. The combination is real nowhere, and the combination is the check.
For example, an engine might happily hand you a machine reporting an 8-core mobile ARM platform, a high-end discrete desktop GPU in its WebGL renderer string, 2 GB of device memory, and a 4K resolution. No such device was ever built. In the real world hardware comes in coherent bundles, a given GPU string co-occurs with a narrow band of CPU counts, memory sizes and platforms, because it only ever shipped inside a handful of actual products. So instead of trying to prove any single value is fake, you check whether the combination is one that reality ever produced. A machine that claims a pairing that has never shipped is not a rare machine, it is a generated one.
The deeper and more stubborn problem for these engines is rendering fingerprinting, so let's actually explain what that is.
When you draw text or shapes onto an HTML <canvas> and then read the pixels back out, the exact bytes you get depend on the whole rendering stack underneath: the GPU, the graphics driver, the OS text rasteriser, anti-aliasing, sub-pixel hinting, the browser build. Two machines with the same stack produce byte-identical output; change any layer and the bytes shift slightly. Hash those bytes and you get a . WebGL is the same idea one level deeper: you render a 3D scene and read it back, and you also just ask the driver who it is:
function webglRenderer() {
const gl = document.createElement("canvas").getContext("webgl");
const info = gl.getExtension("WEBGL_debug_renderer_info");
return {
vendor: gl.getParameter(info.UNMASKED_VENDOR_WEBGL),
renderer: gl.getParameter(info.UNMASKED_RENDERER_WEBGL),
};
}
The useful property here is that nobody chose this value. A user agent string is a sentence the browser has decided to say about itself, and anything that can be said can be said differently. A canvas hash is the arithmetic result of a specific chip, a specific driver and a specific font rasteriser doing their job, so changing it means either lying about the answer or changing the hardware.
When an engine spoofs the GPU, it has painted itself into a corner on these renders. It has really only two honest options, and both are detectable. It can add noise to the output so the hash does not match the real underlying hardware, or it can let the real hardware render and return the true result.
If it adds noise, you catch it statistically. If you hold a database of hundreds of thousands of real fingerprints and you know the canvas render each configuration produces, an unseen hash is a screaming signal. At any given moment there are only on the order of a few thousand genuinely distinct canvas renders in circulation, because they are determined by a finite set of OS, GPU, driver and browser combinations, not by individuality. So a canvas hash that appears in none of your history is far more likely to be noise on top of a render than a real new machine. (If you are going to publish a hard number like "a few thousand", back it with your own corpus size, it is much more convincing than an unsourced figure.)
If instead the engine returns the real render, it can still be flagged, because now the render does not match the fingerprint it claims to be. It says it is an integrated Intel GPU on Windows but produces the canvas output of an Apple GPU on macOS. The render disagrees with the story.
There is a third option the better engines now reach for, which is worth knowing about because it is where the frontier actually is: replaying a real render, an output that was genuinely captured from a matching physical machine and is now served back verbatim. That defeats the "unseen hash" check and the "wrong hardware" check at once. But a render never lives alone. The canvas hash has to agree with the WebGL renderer string, which has to agree with the font list, which has to agree with the audio fingerprint, which has to agree with the screen geometry and the device memory. Replay one signal from one donor machine and stitch it onto a spoofed everything-else, and the cross-checks catch the seam. Consistency across signals is much harder to fake than any single signal.
And there is a lovely tell that sits underneath even that. A real GPU asked to draw the same thing twice produces the same pixels twice, it is deterministic. Many noise implementations perturb per draw rather than per session, so asking the environment to render the identical canvas twice and comparing the two results tells you whether the noise is coming from silicon or from a script. Real hardware is boring and repeatable. Noise is not.
The actual algorithms these engines use to generate their noise can be fingerprinted too, which is exactly what we did here: https://trustsig.eu/blog/multilogin-mimic-canvas-webgl-masking/.
Everything so far has lived inside the browser. But every request also arrives over a network, from an IP, carried by a TLS handshake and an HTTP/2 connection, and that side of the house is an entire onion of its own that does not care one bit how good the attacker's browser spoofing is.
The outermost skin is IP reputation. Where is this address coming from, and what is its ASN? Datacenter ranges (AWS, GCP, the big hosts) are cheap, plentiful, and a very weak place to run fraud from, because they are trivially known. So attackers move to residential and mobile proxies to borrow the address of a real home or phone connection, which is the single most effective thing they can buy, and it is why an IP allowlist or a simple rate limit falls over so fast. Real sensors treat IP reputation as a live, server-driven signal folded in with everything else rather than a static blocklist, precisely because the good addresses rotate constantly.
So the address looks clean, residential, unremarkable. Now the layer inside the layer opens up: is it really a residential user, or a residential proxy with a fraudster behind it? Several things give a proxy hop away. The TCP/IP stack has its own fingerprint, the initial TTL, the MTU, the TCP window and options, and these are set by the operating system of whatever box actually terminates the connection, which is often not the OS the browser claims to be. There is a timing tell too: the gap between the raw TCP handshake completing and the first byte of the TLS ClientHello arriving is unnaturally stretched when the traffic is being relayed through an extra hop. The browser's timezone can be compared against the IP's geolocation, a "German residential" IP driving a browser set to Asia/Shanghai is a mismatch worth a hard look. And WebRTC, if it is not carefully blocked, will happily leak the client's true local address out from behind the proxy entirely.
Chrome 151 on Windows 11one request, claiming one client
4 signalsTLS JA4, the HTTP/2 SETTINGS frame and header order, the User-Agent and sec-ch-ua all answer Chrome 151. Byte for byte, the impersonation holds.
TCP/IP stackinitial TTL 64, MSS 1460, window 65535Linux, not Windows
Handshake gap412 ms between the TCP handshake and the ClientHelloA hop in the middle
Clock against addressAsia/Shanghai, on an address that geolocates to DuisburgThe browser and the exit node disagree
The story is perfect. The storytellers do not match.A real Chrome agrees with itself everywhere, because every one of these comes out of the same binary.
Then there is the layer that pairs directly with the headless-client discussion coming up next, because it is the same escalation ladder. Every TLS client has a fingerprint, and the newer JA4, derived from the exact shape of its ClientHello, the cipher list, the extensions, their order. A plain Python requests script has a TLS fingerprint that looks nothing like Chrome's, so even before you read a single byte of the payload you know it is not the browser it claims to be. The attacker's counter is a library like curl_cffi or tls-client that impersonates Chrome's ClientHello down to the byte. So the detection moves up a level: now you check for consistency. The TLS fingerprint says Chrome 151. Does the HTTP/2 fingerprint (the frame settings, the header ordering, the pseudo-header order) also say Chrome 151? Does the User-Agent? Does the sec-ch-ua client-hints header? A real Chrome agrees with itself across all of these because they all come out of the same binary. An impersonator has to reproduce every one of them, in agreement, or the seams show. This is the exact same pattern as the canvas cross-checks: no single signal is the proof, the agreement between signals is.
The recurring shape here is worth naming, because it comes back in every remaining layer. You almost never catch a good attacker on a value being wrong. You catch them on two values that cannot both be true, and the more independent sources a session has to satisfy at once, the more expensive keeping them all in agreement becomes.
When you are running mass-scale automated attacks, resource cost is everything, and there is a massive difference between two ways of doing the same job. On one side, you spin up a real browser to parse the page, render it, run the site's JavaScript, and then fire the API requests that actually achieve your goal. On the other, you write a lightweight headless client that never opens a browser at all, and just sends the requests directly, mimicking exactly what the site's frontend would have sent.
The gap between those two is not small, it is measured in orders of magnitude. A real browser instance carries hundreds of megabytes of memory, a full rendering pipeline, a JavaScript engine, and the CPU cost of laying out and painting a page it does not even need to look at. A raw HTTP client carries a socket and a few kilobytes of state. Depending on whether you are counting memory per instance, CPU time per cycle, or throughput per machine, you are talking about somewhere between hundreds and thousands of times more capacity from the same hardware. When your goal is volume, and a "cycle" is one full attack (say signup then follow), being able to run thousands of cycles where a browser could run one is the entire economics of the operation.
So the defender's job in this layer is to make not running a browser expensive, and to make the browser you do detect prove it is real. The defense here is multi-layered, and the pieces work best combined.
Proof of Work
gets thrown around as a way to "detect" or "block" bots. It does not do that, at all, and it is worth being loud about it. A proof of work does not tell you whether the client is a human or a script. What it does is two other things.
First, it raises the cost of writing a headless client. To send a valid request without a browser, the attacker has to find the proof-of-work routine in the client code, understand it, and re-implement it in their own client. That is friction on the development side, before a single request is ever sent.
Second, and this is the real purpose, it forces computational cost onto every single cycle, regardless of whether that cycle ran in a browser or a headless client. The client is handed a challenge and has to burn CPU finding an input whose hash satisfies some condition, the classic "find a nonce so the hash starts with N zeroes" shape:
function solveChallenge(challenge, difficulty) {
const target = "0".repeat(difficulty);
let nonce = 0;
while (true) {
if (sha256Hex(challenge + nonce).startsWith(target)) return nonce;
nonce++;
}
}
There is no clever way to solve this. A hash cannot be run backwards, so the only route to an answer is to try numbers until one works, and every extra zero demanded multiplies the number of tries by sixteen. Checking a submitted answer, meanwhile, is a single hash. That asymmetry, expensive to find and instant to verify, is the whole mechanism.
For one legitimate user solving this once, the cost is invisible, a few milliseconds they never notice. For an attacker running tens of thousands of cycles concurrently, that same few milliseconds, multiplied out, is a very real and very deliberate performance tax. It does not stop the attack. It makes the attack cost more, which, remember, is the entire goal.
Two honest caveats, because a strong version of this idea has to survive its own counters. The attacker's move against a fixed proof of work is to lift it out of the slow JavaScript engine and run it in native code or on a GPU, where it goes orders of magnitude faster, which is why the difficulty cannot be a constant. It has to scale with the risk score, cheap for a session that looks fine and punishing for one that looks automated, so the tax lands on the attacker and not the grandmother. And the "users never notice" line is getting shakier as proof-of-work walls spread, because on a low-end phone a heavy challenge is felt. It is a cost you are spending, so spend it where the risk is.
A sandbox versus a full client reimplementation
If an attacker wants to avoid running a full browser engine like Blink, they have two roads, and both have their uses and their weaknesses.
The first road is to reverse engineer and re-implement the entire client, rebuilding by hand everything the site's frontend does: which values it probes, how it assembles its payloads, how it solves the proof of work. This works beautifully if the target has no real defenses, because a site without bot protection never rotates its client-side code, the payload format and the proof-of-work routine sit still, so you reverse it once and you are done. In that situation, a full reimplementation is the best tool the attacker has.
But against a strong anti-bot service, the client script is heavily obfuscated, often virtualized, and rotated very frequently, sometimes many times per hour. Now the reimplementation road is brutal: the attacker has to reverse a moving target, rewrite their client, and keep it in sync with rotations that happen faster than they can realistically keep up. It becomes close to impossible to maintain. (Large language models have genuinely moved this line, they are very good at chewing through obfuscated code, so a rewrite that used to take a human a week can take much less now. But rotation turns a one-time reverse into a treadmill, reverse it again and again on a clock, and that is a different and much harder problem than any single deobfuscation, which is exactly the pressure point the defense leans on.)
That is why the second road exists: run the client's own JavaScript, unmodified, inside a lightweight sandbox on top of an engine like V8. Now you do not need to understand the client at all, and you do not care when it rotates, you just execute whatever it hands you and read out the answer. It sidesteps the entire reverse-engineering treadmill in one move. The catch is that your sandbox has to be bulletproof, indistinguishable from a real browser, because the client script's whole job, once it is running, is to look around and figure out whether it is in a real browser or not.
Engine checks
This is where it gets fun, because there are an enormous number of ways to tell a sandbox from a real browser engine, and most of them do not look like checks at all. Let's walk a few of the interesting ones.
The most primitive approach is volume: make a lot of DOM and rendering calls and see if the sandbox keeps up correctly. For a sandbox to survive this it has to re-implement each of those APIs to behave exactly like a browser, which is expensive, but it is a cost you pay once and then your sandbox is permanently stronger. So the crude version is a war of attrition the sandbox can eventually win.
The elegant version goes low-level and asks questions whose answers depend on quirks of the real engine that a naive emulation gets subtly wrong. A favourite is WebIDL integer conversion. Many DOM methods declare their arguments as, say, unsigned long, and the spec says the browser must coerce whatever you pass into a real 32-bit unsigned integer before using it. So 2 ** 32 (one past the max) wraps around to 0:
const nodes = document.querySelectorAll("div");
nodes.item(4294967296);
In a real browser, 4294967296 is coerced to 0 and you get the first node back. In a sandbox that just treated the argument as an ordinary JavaScript number, no wraparound happens, the index stays 4294967296, and you get null. Same call, different answer, and the difference is a fingerprint. The whole family of [Clamp] and [EnforceRange] typed conversions gives you the same kind of leverage.
A 32-bit counter can hold numbers up to 4294967295 and then rolls over to zero, the way a car odometer does. The browser is required by spec to run that rollover before it uses the number, so asking for element 4294967296 really does mean asking for element 0. Nobody re-implements an odometer they did not know was there.
Then there is native-code integrity. In a real engine, a built-in function stringifies to a specific shape:
Function.prototype.toString.call(document.createElement);
A genuine Chrome answers "function createElement() { [native code] }". A sandbox that shimmed createElement in JavaScript returns the source of the shim instead, unless it went to the trouble of masking every single function's toString, and its accessor display names (function get userAgent() { [native code] }), and its prototype chains, and the exact shape of an error stack thrown from inside a native call. Miss one and you leak. This is also why the real sensor we looked at earlier was calling toString on its own critical function thirty times, it is defending the same property it knows attackers are attacking.
Missing surface is the bluntest tell of all. A real browser exposes a gigantic API, and an emulation only implements the parts it thought it needed. So you probe for the unglamorous corners: File, FileList, BroadcastChannel, PaymentRequest, DOMException, FocusEvent, InputEvent, ClipboardEvent, the PublicKeyCredential authenticator interfaces, AudioWorklet, document.styleSheets, previousElementSibling. A sandbox that forgot DOMException will not just answer "undefined" politely, code paths that were supposed to throw a DOMException instead blow up with a ReferenceError, which is an even louder signal. And the shapes have to be right, not just present: in a real browser navigator.mimeTypes stringifies to [object MimeTypeArray], but a sandbox that faked it with a plain object answers [object Object].
One concrete leak of this shape is worth showing in full, because it is such a clean example of how small the tell can be. A live DOM collection like document.images is not an array, it is a live HTMLCollection, and it behaves differently when you poke it. A real sensor read a property off it and, because of how the live collection responds, serialized it as "images":1. The sandbox had backed document.images with an ordinary array, so the exact same code serialized "images":[]. One character in the payload, 1 versus [], and the whole disguise is off. The attacker's fix is real work: parse the served markup into an actual document instead of a skeleton of forms and fields, make inserting a DocumentFragment move its children the way it does in Chrome (which is what makes jQuery's .html() insert real nodes and pull in the extra resources a real page would), get document.currentScript to fall back to null once parsing ends. Every one of those is a browser behaviour the sandbox has to grow before it stops leaking.
The audio stack is its own little world of this. Ask an OfflineAudioContext to render an oscillator through a compressor and sum the samples, and a real browser produces a specific number; a sandbox has to reproduce it exactly. Feed a broken buffer to decodeAudioData and a real browser rejects with an EncodingError DOMException, so the sandbox has to reject the same way, with the same error name, or it is caught in the exception handler. Even performance.memory is a tell, in a real browser it reads the actual V8 heap and the numbers grow between measurements as the page does work; a sandbox that returns a constant is a sandbox.
And underneath all of it, timing. It is not enough for each faked call to return the right value, it also has to take a believable amount of time. A real browser has a natural rhythm: events fire in a certain order and with certain gaps (a deviceorientation and devicemotion arriving roughly 80 ms after you attach the listeners, a keypress landing between keydown and keyup, a window focus when the input stream starts). If the sandbox is crediting itself back the milliseconds the host spent pacing the wire, or advancing its own clock in a way a real page never would, the timing profile disagrees with the value profile. A sandbox that gets every value right and every duration wrong is still a sandbox.
Raising the cost of building the client
Whichever road the attacker takes, sandbox or full rewrite, they first have to understand the client: what it probes, how it builds its payloads, what it checks. In a conventional setting that is easy, because beyond minifying or packing the JavaScript there is not much standing between the attacker and comprehension.
This is where obfuscation, and more importantly (which inherits from obfuscation), earns its place. The goal of obfuscation is to make the code's control flow and semantics so confusing and unconventional that understanding it requires the slow, tedious grind of reversing and cleaning it before you know what the core actually does. It does not make the code impossible to understand. It makes it expensive, and expensive is the whole business.
The lighter techniques stack up first. String literals get encrypted and decrypted at runtime so you cannot grep for webdriver or canvas. Control flow gets flattened, the natural if/else and loop structure is smashed into a single dispatch loop driven by a state variable, so the linear story of the code disappears. Opaque predicates, branches whose outcome is constant but not obviously so, get sprinkled in to send anyone reading it down dead ends. The payload itself gets scrambled on the way out, shuffled by a seeded pseudo-random pass and character-substituted through a table, where the seed is pulled from a cookie the server set, so even the shape of the data on the wire is a moving target you have to reconstruct.
The heavy technique is a full virtual machine. Instead of shipping JavaScript that does the work, the client ships a small bytecode interpreter and a blob of bytecode, and the interpreter runs the bytecode. Now the attacker cannot just read the logic, because the logic is not expressed in JavaScript anymore, it is expressed in a custom instruction set that only this interpreter understands, and they have to reverse the VM before they can even begin reversing the program running on it. A stripped-down version of the dispatch core looks like this:
function BytecodeMachine() {
this.stack = [];
this.bytecode = [];
this.pc = 0;
}
BytecodeMachine.prototype.decodeProgram = function (base64Program, keySeed) {
const bytes = [];
const binary = atob(base64Program);
let checksum = 0;
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
checksum ^= bytes[i];
}
const shift = (checksum + keySeed) % 256;
this.opcode = (raw) => (raw + shift) % 256;
this.installOperations();
return bytes;
};
BytecodeMachine.prototype.run = function () {
let op = this.bytecode[this.pc++];
while (op !== this.opcode(254)) {
this[op](this);
op = this.bytecode[this.pc++];
}
};
Look at what decodeProgram does, because it is the nastiest part and it is a perfect illustration of the philosophy. The numeric value of every opcode is shifted by an amount derived from a checksum of the bytecode plus a key seed handed in from outside. That means the same instruction has a different byte value in every build. You cannot write down a static opcode table and reuse it, because the mapping is regenerated per program, and the key seed is bound to the environment. Combine that with frequent rotation, a fresh VM and fresh bytecode pushed several times an hour, and even a successful reverse-engineering has a shelf life measured in minutes.
Reversing a virtual machine normally starts with building a table: opcode 3 pushes a value, opcode 17 compares two, and so on. That table is the reward for the first few days of work and it is what makes every later build readable. Deriving the opcode numbers from a checksum plus an external seed means the table you just built describes exactly one build and nothing else.
The final turn of the screw is environment binding. The obfuscation is wired into the DOM and the real browser environment so that the code does not merely run better in a real browser, it only runs correctly there. Decryption keys are derived from values that only exist in a genuine browser context, so lifting the script out and running it in a bare sandbox produces garbage rather than the right answer. This is the idea that closes the loop with the sandbox section: the defender's strongest position is not "detect the sandbox" but "make the code refuse to work anywhere except a real browser", which forces the attacker back toward running a real browser, which is exactly the expensive thing this whole layer was trying to force in the first place.
Suppose the attacker gives up fighting the engine and just runs a real browser, spoofed cleanly, driven by a stealth driver, on a residential IP. The technical layers are quiet. Now we watch how the thing behaves, because a script moving a real browser still does not move like a hand.
A sensor sits on the whole spread of input events and records them as compact streams: mousemove, mousedown, mouseup, click, pointerdown, pointerup, touchstart, touchmove, touchend, keydown, keyup, keypress, plus deviceorientation, devicemotion, and the blur/focus/visibilitychange transitions that tell it when the page had your attention. For each event it keeps a small tuple, a sequence number, the kind, a time offset from when tracking started, coordinates, and a running checksum, and it caps how many of each it keeps so it never turns into a memory leak. Out of that you get the shape of the interaction: the curvature and velocity of the mouse path, the dwell and flight time between keystrokes, the physics of a scroll, whether touch events on a "mobile" session actually look like fingers.
The single most valuable field in all of that is not any coordinate. It is isTrusted. Every real user event carries isTrusted === true, set by the browser itself and impossible to forge from page JavaScript. An event you synthesise, by dispatching it yourself or driving the page through the automation protocol in certain ways, comes through with isTrusted === false. So the sensor tags any untrusted event in its stream and keeps a pair of counters:
function updateTrustedCounters(event) {
if (event.isTrusted === true) trustedCount++;
else untrustedCount++;
}
The browser stamps this flag itself, at the moment the event is created, before any page code can reach it. A script can build an event that looks identical in every other respect and dispatch it at any element, and the stamp will still say the event did not come from a physical device. It is one of the very few things in the page that a page cannot lie about.
On its own, one untrusted event is not damning, extensions and legitimate scripts fire synthetic events all the time. But once untrusted events pile up past a threshold, and especially when a whole class of interaction (every "click", every "keydown") is synthetic while nothing trusted ever appears on the same element, the sensor stops waiting and fires a dedicated telemetry post flagged as exactly that condition. It has effectively caught the automation red-handed, not by a missing property, but by the fabric of the events themselves.
Around the raw streams sits a layer of context: the sensor classifies form fields without ever storing what you typed. It matches each input's name, id, placeholder and aria-label against a dictionary to decide whether a field is a username, a password, an email, a phone number, and then records the metadata of your typing, how long the value is, how many letters versus digits versus symbols, the timing, but not the value. So it learns that "the email field was filled in 180 ms with zero corrections and a value that is all one paste" without ever learning the email. That is enough to tell a human filling a form from a script populating one.
So far we have only looked at the technical environment and the session itself. But there is so much more, and one of the most important and most overlooked pieces is the actual , the meaning of what the user is doing.
Business logic here is anything the application does: a signup, a follow, a purchase, a withdrawal. This is one of the largest things left out when bot and fraud detection gets talked about, and it is the layer that still works in the nightmare scenario where the attacker has beaten everything above, a real clean device, a real residential connection, real human hands. When the machine and the network tell you nothing, the sequence of actions still can.
Business logic analysis is not very useful on its own, like every other layer it wants to be combined. A primitive example: a brand-new account signs up, goes straight to the profile of JohnDoe123, and follows. Looked at alone, that reveals almost nothing, people do exactly that every day. But two things make it powerful. First, cross-referencing it with the technical and network layers, if a thousand brand-new accounts all perform that identical pattern, on borrowed residential IPs, with slightly-too-clean fingerprints, the business logic is the thing that clusters them. Second, the sequence itself can be checked for coherence: did this session ever request the profile page the UI would have had to load before that follow button existed, or did it call the follow endpoint out of nowhere? Real users leave the exhaust of the interface they walked through. A script that skips straight to the endpoint that matters skips the exhaust.
The richest version of this is the graph. The same virtual card across two hundred accounts, the same device fingerprint under fifty usernames, the same phone number, the same three-hop follow pattern at the same time of day, the same velocity. No single account looks wrong. The graph of how they connect to each other is where the automation cluster becomes undeniable, and it is where "bots" and "fraud" finally meet: the behaviour clusters the sessions, and the shared identity artifacts prove the cluster is one actor.
We have peeled a lot of layers, and if you take any one of them in isolation you can defeat it, that has been the quiet theme the whole way down. A stealth driver beats the flags. An anti-detect engine beats the fingerprint. A residential proxy beats the IP. A bulletproof sandbox beats the engine checks. A patient human beats the biometrics. Every layer, alone, loses.
The system does not live in any one layer. It lives in the combination, and this is the part that gets left out of almost every explanation of how this works.
One signup, 41 seconds, no check failed outright
Canvas renderHash matches nothing in the history, and shifts between two identical draws-6
Hardware combinationRTX 4080 renderer string, 2 GB of device memory, ARM platform-14
Pointer events28 of 31 arrive with isTrusted false, on elements nothing trusted ever touched-31
Clock against addressAsia/Shanghai browser on a German residential address-9
Action sequenceFollow posted with no profile fetch anywhere before it-12
TLS, HTTP/2, User-AgentAll three agree on Chrome 151no charge
Keystroke rhythmFlight times spread from 41 to 190 ms, with two correctionsno charge
28trust score, from 100
Five readings, none of them damning, none of them a verdict. Seventy-two points of them, and the session has said what it is without ever failing a single yes or no question.
None of these checks return "bot" or "not bot". They return signals, and the signals get weighted and summed into a score. A spoofed-looking canvas is worth a little. An impossible hardware combination is worth more. A pile of untrusted events is worth a lot. A residential IP whose timezone disagrees with the browser is worth something. The follow-without-the-profile-request is worth something. No single one crosses the line, but the onion is additive, and a session that trips a dozen small signals at once has told you exactly what it is even though it never failed a single binary check.
This is why arguing about any individual check misses the point. Every one of them can be beaten, and every one of them is cheap to run, so beating one buys the attacker a few points back and nothing else. The expensive requirement is arriving clean on all of them at once, in a session that also has to move like a person and act like a customer.
And it is not one decision at one moment, either. Watch how a real sensor actually behaves and you will see it is not fired once at page load, it posts continuously, on triggers: after enough mouse movement, on an Enter or a Tab keypress, on a pointer interaction, on device motion, on form submit, on idle. Each post is tagged with which trigger produced it. Server-side, that is not a snapshot, it is a stream, a behavioural film of the whole session, and risk accumulates across it and across the lifetime of the account behind it. This is what the anti-bot cookie is quietly coordinating: it carries a little state machine, how many sensor posts the server still wants before it will trust this session, a state marker, a heartbeat timestamp that forces the client to keep re-proving itself over time. The server can look at the early signals and simply ask for more evidence, and a headless client that does not keep feeding that stream correctly falls out of the state machine on its own.
BlockRefuse the request outrightTells the attacker exactly which cycle was caught, in the second it was caught. That is free training data, and they will spend it.
Step up an SMS or a captcha, for the suspicious slice onlyThat something in this cycle looked wrong, but not which partChallenge
Answer, slowlyNothing they can tell apart from a bad afternoonDelay
Let the follow register on their screen and nowhere elseNothing, until they think to check the other sideShadow limit
The attacker's expensive problem is not being stopped. It is not knowing what stopped them.
Then there is the last decision, which is not detection at all but response, and it is where a lot of the real cleverness lives. Catching the fraud is only half of it, what you do about it is the other half, and "block" is the worst option on the menu. Block, and you have told the attacker precisely which cycle got caught, which is free training data for their next attempt. So the mature systems rarely just block. They challenge (step up a captcha or an SMS only for the suspicious slice, spending friction only where the risk is). They delay. They shadow-limit, let the action appear to succeed while quietly capping its reach, so the fake follow registers on the attacker's screen and does nothing in the real graph. They let the request through and neuter it downstream. The attacker's most expensive problem is not getting blocked, it is not knowing why, and a system that hides which layer caught them turns every one of their attempts into guesswork. This, finally, is why obfuscation and rotation are worth the effort: not because the code can never be reversed, but because the moment an attacker learns which signal betrayed them, they fix it, so the whole architecture is built to never tell them.
That is the onion. Not a wall, not a flag, not a check. Layers on top of layers on top of layers, each one beatable alone and formidable together, feeding a score that accumulates over the life of a session, answered by a response designed to give nothing away. And the reason it is built this way comes right back to the goal we started with: you are never trying to make fraud impossible. You are trying to make it cost more than it is worth, and then quietly, patiently, layer by layer, making sure it does.
TrustSig is an invisible bot-detection and device-identity platform built in the EU. If you are building any of the layers above, or breaking them, get in touch.