Skip to content

The integrity-check arms race: how anti-bot JS detects it's being debugged

· 22 min read
Copyright: MIT
The word debugger in monospace, struck through with an orange bar, over a stepped state machine

Open the DevTools on a page protected by a serious anti-bot vendor and something pushes back. The tab freezes on a debugger statement you didn’t set. Close it, and it reappears two lines later. Reformat the minified blob to read it and the script silently corrupts its own output, or hangs the tab, or quietly flips a flag in the payload it ships back to the server. None of this stops the page from working for a normal visitor. It only happens to the person with the inspector open. That is the point. The script has decided it is being analyzed, and it is responding.

This is the anti-analysis layer, and it is a genuine arms race rather than a fixed defense. Every trick on the defender’s side has a known counter, every counter has a counter-counter, and the whole thing has been iterating in public for over a decade. This post walks the layer from the bottom up: the debugger trap and why it is more than an annoyance, the timing checks that infer a paused interpreter, the side channels that detect an open inspector without ever asking the browser, the toString integrity checks that catch a hooked function, and the self-defending code that detects its own beautification. For each, the move and the answering move. None of these is a product pitch and none is a working bypass; the goal is to understand the machine well enough to reason about it.

The debugger statement, and why it is not just an annoyance

The debugger keyword is the bluntest tool in the set. When an interpreter hits it with no DevTools attached, it does nothing. With DevTools attached, it acts as a hard breakpoint and pauses execution. A defender who wants to make manual stepping painful drops debugger into a loop and the analyst’s tab becomes unusable, pausing on every iteration. The crudest version is a single line:

setInterval(function () { debugger }, 100)

The point is not that this is hard to defeat. It is that defeating it cleanly is harder than it looks, because the naive defeats are themselves detectable. Chrome ships a “Deactivate breakpoints” toggle and a per-line “Never pause here” option, and both make the breakpoint stop firing without touching the script. That is the first move on the analyst’s side, and against a dumb loop it wins outright. The defender’s answer is to stop relying on the breakpoint actually pausing and instead measure whether it did.

That measurement is the real mechanism. A debugger statement that fires steals wall-clock time while the analyst sits paused at it. Wrap the statement in a timer and the difference between “ran in microseconds” and “ran in four seconds because a human was staring at the call stack” is enormous and trivially observable. So the construct that matters is not the loop, it is the loop plus a clock.

t0 = performance.now() debugger dt = performance.now() - t0 no DevTools: dt < 1 ms → pass dt > threshold → analyst paused → react *The debugger statement as a stopwatch: the keyword pauses an attached debugger, and the surrounding timer turns "did it pause" into a measurable number.*

A USENIX Security 2021 study by Marius Musch and Martin Johns, “U Can’t Debug This,” built a crawler-driven analysis to find this behavior in the wild and confirmed that anti-debugging is a real, deployed practice rather than a curiosity, with the debugger statement and timing checks among the families it catalogued. The takeaway from that work is less any single number and more the confirmation that the layer is widespread enough to be worth studying systematically.

The analyst’s counter to the timed loop is to make the debugger statement vanish before it ever runs. You can rewrite the function: pull its body with toString, strip the keyword, rebuild it with Function, and swap it in. Greasyfork userscripts have done exactly this against ticket-site loops for years. It works until the defender hides the debugger behind an eval evaluated in a separate context, or behind an integrity check that notices the function body changed, at which point the rewrite either misses the trap or trips a different one. The clean win, documented by analysts who fought Supreme’s and JScrambler-protected loops, is to stop fighting in JavaScript at all and rename the keyword in a patched browser build, so that debugger in the page text no longer maps to the interpreter’s breakpoint. Researchers patched Firefox to rename the token to something like ticket_debugger, which neutralizes every debugger-based trap at once because the engine no longer recognizes the word. That is the shape of the whole arms race in miniature: the defender moves up one layer of abstraction, so the analyst moves down one.

If you want the layer below the JavaScript, where the breakpoint and the page’s own runtime live, the Chrome DevTools Protocol as a tracing surface is where that fight actually happens.

Timing checks without a debugger statement

The debugger-plus-clock pattern is a special case of a more general idea: a paused or instrumented interpreter runs slower, and slowness is measurable from inside the script. You do not need a debugger statement to use this. Any block of code whose normal runtime is well-characterized becomes a probe. Sample the clock, run the block, sample again, and compare the delta against the baseline you expect on real hardware. If a human has the interpreter paused mid-block, or a tracer is logging every property access, the delta blows out.

The classic form compares two Date.now() or performance.now() reads bracketing a tight loop, with a fixed threshold above which the script concludes it is being watched. Published write-ups use thresholds on the order of a couple hundred milliseconds for the coarse “is a human stepping through this” case, because human reaction time at a breakpoint is enormous compared to uninstrumented execution. The finer-grained version, aimed at automated tracing rather than a paused human, watches for the steady tax that hooking every function call or property getter imposes, and it is much harder to tune because it has to separate that tax from ordinary jitter, garbage-collection pauses, and a busy main thread.

Browsers have spent years blunting the precision side of this. After Spectre, performance.now was clamped to coarser resolution and SharedArrayBuffer, which let scripts build their own high-resolution timer, was gated behind cross-origin isolation headers. That cuts both ways. It limits a malicious site’s timing side channels, and it also limits an anti-bot script’s ability to measure microsecond-scale instrumentation overhead. A defender who needs fine timing today has to either accept coarse clocks or coax precision out of indirect sources, and the precision simply is not what it was in 2017.

The coarse clocks survive for the kind of detection that still works at that resolution, which is the human-at-a-breakpoint case rather than the trace-overhead case. A person who pauses to read a call stack burns hundreds of milliseconds at minimum, often seconds, and you do not need a nanosecond timer to see a gap that large. That is why the published debugger-plus-clock thresholds sit in the hundreds of milliseconds: they are tuned for the coarse signal that a human is present, not the fine signal that a tracer is logging. The fine-grained detection, the kind that would catch an automated hook adding a few microseconds per call, is the casualty of the clock clamps, and it is exactly the kind of detection an analyst most wants to dodge. The result is an asymmetry the analyst can use. Automated tracing that adds small, uniform overhead is now hard for the script to distinguish from ordinary main-thread noise, while the obvious human pause is still trivially visible. So the durable analyst posture is automated and non-pausing, never stopping the world, and the durable defender posture is to stop measuring time at all and check for the hook directly.

The analyst’s counter to timing checks is conceptually simple and operationally annoying. If the script measures wall-clock time across a region, do not pause inside that region. Set the breakpoint after the check, or use a conditional breakpoint that logs and continues without stopping the clock, or hook the timer itself so that the two reads it sees are close together regardless of what happened between them. That last move, lying to the script about time, is durable, but it raises a question the defender then exploits: did you hook performance.now, and can the script tell? Which is the next layer.

Detecting an open inspector without asking

The browser gives no API for “are the DevTools open.” So detection relies on side effects, things that change observably when the inspector is present, and the history here is a steady migration from crude geometry to precise side channels.

The crude method measures the viewport. Docked DevTools eat screen space, so the gap between window.outerWidth and window.innerWidth (or the height equivalent) jumps when the panel opens. Poll that difference against a threshold in a loop and you have a cheap open/closed signal. It is also wrong constantly. It misses undocked DevTools entirely, since a separate window steals no space from the page, and it false-positives on any sidebar, browser extension panel, or narrow window. Sindre Sorhus’s widely-used devtools-detect library is built on this geometry approach and its own README is blunt about the limits: it does not work when DevTools is undocked and shows false positives when you toggle a sidebar, and the author notes the detection now has too many false positives because browsers changed underneath it. The geometry signal is legacy. Nobody serious relies on it alone.

The precise method exploits the fact that the console is lazy. When you console.log an object, the browser does not render it unless something is actually going to look at it, which in practice means unless the console is open. So you hand the console an object with a booby-trapped accessor and wait to see if it goes off.

const bait = /probe/; bait.toString = () => { flagged = true; return ''; }; console.log('%c', bait); DevTools closed object never rendered getter not called DevTools open console renders the object getter fires → flagged = true *The lazy-console side channel: the rendering of a logged object calls its accessor, so the accessor firing reveals that something is rendering it.*

The same idea works through the debugger rather than the console. When execution pauses at a breakpoint, the DevTools Scope pane renders the local variables in view, and rendering them reads their values. Gal Weizman’s 2021 write-up turned that into a precise probe: put an object with a getter into a function’s local scope, and the getter fires only when the Scope pane paints, which is to say only when a breakpoint pauses inside that specific function. Unlike the geometry check, this distinguishes “stepped into the sensitive function” from “stepped over it,” so the defense can react narrowly. Weizman also noted the self-protecting twist, that the detection callback itself resists debugging because pausing in it re-triggers the very scope rendering that powers it. He framed this as needing the devtools of the devtools to debug it at all, a small paradox that makes the probe unusually durable: the natural way to investigate a suspected anti-debugging check is to step into it, and stepping into this one sets it off.

What both of these have over the geometry method is that they report a real event rather than a noisy proxy for one. The viewport gap is a guess that happens to correlate with an open inspector on a docked desktop layout and falls apart everywhere else. The lazy-console getter and the scope-pane getter fire on the actual act of rendering, so a false positive requires something to genuinely render the bait object, which ordinary page code has no reason to do. That precision is why the side-channel approach displaced the geometry approach for anything that matters, and why a defender who still ships only the width check is signalling that they are not really trying.

The analyst’s counter is to deny the side effect. The lazy-console getter goes off because the console renders the object, so the move is to override console.log (and .debug, .table, and friends) before the script captures references to them, so the bait is never rendered. The Scope-pane variant is harder, because the trigger is the debugger’s own UI, not a function the analyst controls; the practical answer there is to avoid pausing inside instrumented functions and lean on logging breakpoints that never stop. And the geometry check falls to simply undocking the inspector. But notice what the console override requires: replacing a native function with your own. Which the script can check for. That is the next layer, and it is the deepest one.

toString integrity: catching a hooked function

Everything above eventually forces the analyst to replace something native, a timer, a console method, a getter. The integrity layer exists to catch exactly that. Its workhorse is Function.prototype.toString.

In every major engine, calling toString on a native function returns a stylized string with a [native code] placeholder where the body would be, like function now() { [native code] }. A function written in JavaScript returns its actual source. So the simplest hook detector is one line: does the function’s stringification still contain [native code]? If you replaced performance.now with your own JavaScript timer, its toString now shows your source, and the check fires.

This is shallow, and the bypasses are well-documented, which is exactly why the real implementations are layered. Matteo Mazzarolo’s 2022 write-up walks the escalation. The naive f.toString().includes("[native code]") is fooled by hiding that literal string in a comment in your replacement, or by overriding the replacement’s own toString to lie. An ES6 Proxy wrapping the native function is sneakier still: a Proxy that only traps apply leaves toString passing through to the genuine native function, so the stringification still reads [native code] while every call routes through your trap. The text check sees nothing wrong.

So the defender stops trusting the function’s own toString and reaches for a reference it controls. The hardened check calls Function.prototype.toString.call(suspect) using a toString captured at script start, before any analyst could swap it, which sidesteps a per-function toString override. Better still, skip stringification and compare identity: stash the native reference at load time and later test window.fetch === originalFetch. Identity comparison cannot be trapped by a Proxy, because a Proxy is a distinct object and === against the saved original returns false. As Mazzarolo puts it, the strict reference check avoids every toString loophole and even works on proxies because they can’t trap equality. That is the strongest form, and serious anti-bot scripts capture a wall of native references in their first few milliseconds for exactly this reason.

check defeated by f.toString().includes('[native code]') comment with the literal; override toString Function.prototype.toString.call(f) Proxy trapping only apply() compare against iframe-clean reference CSP blocks iframe; patched iframe APIs f === savedOriginalReference untrappable by Proxy — identity, not text each rung assumes the script captured its references before the analyst loaded *The integrity check climbs from a string match anyone can fool to an identity comparison a Proxy cannot trap; the catch is that the bottom rung only holds if the script ran first.*

That last clause is the whole game. Identity comparison is unbeatable in the abstract and entirely beatable in practice if the analyst’s hook is installed before the script captures its reference. Patch performance.now in a browser extension content script that runs at document_start, or in a CDP Page.addScriptToEvaluateOnNewDocument payload that executes before any page script, and the “original” the anti-bot code saves is already your hook. The reference it trusts is poisoned at birth. So the contest moves off the page again, into who controls the runtime first, which is why source-patched browsers and pre-document injection matter so much to this fight. The flip side is that an instrumentation framework that hooks after page load, or that leaves any timing or identity tell, gets caught by precisely these checks. This is the same surface that catches stealth tooling generally, the reason stealth plugins lose over time is that each new native-reference or identity check finds one more property they patched at the wrong layer.

These integrity checks rarely travel alone. They sit inside heavily obfuscated payloads where the function being verified is also string-encrypted and control-flow-flattened, so that finding the check, understanding what it guards, and neutralizing it without tripping a second check is the actual work; the mechanics of that obfuscation are their own topic in control-flow flattening and string encryption.

Self-defending code: when the script detects its own beautification

The integrity idea generalizes from “is this native function hooked” to “has my own source been touched.” An obfuscated script can hash or pattern-match parts of itself and react if the result is wrong, which is how self-defending mode in tools like obfuscator.io works. The clever part is that it weaponizes the analyst’s first instinct, which is to beautify the minified blob so it can be read.

The technique, dissected in a Trickster Dev write-up, injects a check built around a deliberately pathological regular expression run over a function’s own stringified source. The pattern is of the catastrophic-backtracking family, something close to (((.+)+)+)+$. Against the original one-line minified source it completes instantly. Add the whitespace, line breaks, and indentation that a beautifier inserts and the same regex sends the engine into exponential backtracking, pinning a CPU core and hanging the tab. The script does not announce that it detected tampering. It just becomes unusable in the exact way that punishes the person who reformatted it, while running fine for everyone who left the source alone. The beautification is the trigger.

This pairs with periodic debugger injection (the debug-protection feature) so that an analyst faces both a self-corrupting hang on reformat and a breakpoint storm on stepping. Akamai’s own write-up on JavaScript obfuscation lists the same family from the defender’s side, naming overridden debug functions, infinite breakpoint loops that activate only when a debugger is running, execution-timer traps for code slowness, and detection of code renaming by evaluating code integrity. Different vendor, same catalogue, which tells you these are settled techniques rather than anyone’s secret.

The counter is to refuse to fight the check on its terms. You do not beautify the live script and run it; you take the source offline and transform it where no integrity check is executing. Parse it to an AST, find and excise the self-defending and debug-protection nodes statically, and only then format the result for reading. The Trickster Dev analysis is explicit that these protections are not permanent: they raise the cost, and they fall to AST-level removal or careful manual edits made before the code runs. The catastrophic-regex check can only fire if it executes, so an analyst working in a static rewrite, never letting the guarded code run against its own modified text, sidesteps the trap entirely. The arms-race move underneath this is for the defender to tie the integrity check to a value the page actually needs, so that removing the check also removes a decryption key or a payload field, and the script fails closed rather than failing loud. That is the difference between annoyance-grade self-defense and the kind built into a commercial anti-bot token.

How the layers compose in a real anti-bot tag

No vendor ships these as isolated gimmicks. In a deployed tag they interlock, and the interlock is what makes the layer expensive to peel. A representative composition, inferred from public analyses of commercial tags rather than from any vendor’s source, runs roughly like this. The script’s first act, before page scripts can interpose, is to snapshot native references: the timers, the console methods, the toString it will use to judge everything else, the prototype methods it cares about. Those snapshots become the trusted baseline for every later identity check. Then the anti-analysis probes run continuously and feed a result, not into an alert, but into the telemetry payload the tag was always going to send.

That last point is the one most worth internalizing. The modern version of this does not pop alert("Debugger detected!"). A 2010-era proof of concept did; a 2025 commercial tag does not, because announcing detection just teaches the analyst where the check is. Instead the result of “a debugger paused here” or “a native function was hooked” or “the source was reformatted” becomes one more signal folded into the encrypted blob that gets scored server-side, alongside the canvas hash, the timing of the first interaction, and a hundred other inputs. The client never renders a verdict. It reports observations and lets the backend decide, which is the same architecture every serious vendor uses for the rest of its signals; the split between what the JavaScript collects and what the server concludes is covered in server-side versus client-side bot detection.

snapshot native references first timing / debugger probe devtools side channel toString / identity check encrypted payload field server-side scoring no alert, no client verdict — the detection is just another input *Anti-analysis as telemetry: the probes do not block, they tag the payload, and the page that looks normal to a debugged client has already reported the debugging.*

This is why the practical effect of the anti-analysis layer is not “you cannot debug it” but “debugging it changes what it sends.” An analyst who steps through with a stock browser is no longer reverse-engineering a static artifact; they are perturbing a live experiment that is recording the perturbation. The serious approaches account for that by neutralizing the probes before they read anything true, which is the same reason the fight keeps sinking below JavaScript into patched engines and pre-document injection. You either defeat the observation or you accept that your analysis session is a labeled data point on the defender’s side.

The shape of the race

Step back and the whole layer has one structure. Each defensive trick assumes it gets to observe something the analyst cannot perfectly fake, and each counter works by either denying the observation or moving to a layer the trick cannot see. The debugger statement assumes the keyword reaches a real breakpoint, so the analyst renames the keyword in the engine. The timing check assumes a paused or hooked interpreter is slower, so the analyst lies about the clock. The integrity check assumes the script captured the genuine native reference, so the analyst poisons the reference before the script runs. Up, then down, then up. The layer that wins, for a while, is whichever side controls the runtime closer to the metal, which is exactly why the frontier has been migrating from clever JavaScript into source-patched browsers and CDP injection that runs before a single line of page script executes.

What has actually changed over the last few years is less the catalogue of tricks, which is remarkably stable from the 2020 and 2021 write-ups through to today, and more the precision available to each side. Browsers clamped the timers that made fine-grained timing detection possible, which hurt the defenders’ microsecond probes and the attackers’ side channels alike. The detection of new-headless Chrome shifted to the CDP Runtime.enable signal, surfaced publicly in 2024, which is an anti-instrumentation check that has nothing to do with debugger statements and everything to do with the fact that the automation framework, not the page, leaks. And the verdict moved entirely off the client, so that the most important thing the anti-analysis layer does now is not stop you. It quietly notes that you tried, attaches that note to everything else it knows about the session, and ships it before your breakpoint ever resolves. The page kept working the whole time. That was always the tell.


Sources & further reading

Further reading