The 4-line check protecting your six-figure WAF
Your enterprise WAF cost six figures. And its first question to determine if you’re a robot is a 4-line JavaScript check.
The principle: when a bot spoofs its canvas, timezone, or GPU, it replaces native browser functions with its own versions. The WAF calls toString() on these functions. A real native returns "[native code]". A modified function returns its source code. Instant detection.
Every enterprise WAF uses this. Here’s how it falls.
The attacker doesn’t try to prevent the check. They replace toString itself. The WAF calls toString() on a modified function, toString responds "[native code]", and the WAF validates. The check works perfectly.. it just validates a lie.
WAFs have escalated:
- Calling
toString(toString)to verify if toString itself is modified.. The bot countered by self-validating. - Inspecting the prototype chain for injected getters.. A few weeks of relief, then bots cleaned up the traces.
- Triggering errors and analyzing stack traces.. Bots intercepted error creation to sanitize stack traces.
Same cycle every time: the WAF finds an angle, it holds for a few weeks, the bot adapts. A check that lasted 6 months in 2023 holds for a few weeks today.
And it’s not a quality problem. It’s the architecture. The bot runs in ITS browser, on ITS machine. Everything your JavaScript observes, the bot can spoof. You stack more checks, you’re just asking it to lie one layer deeper.
Your trembling hand is your proof of humanity
When you rest your mouse and read a paragraph, your cursor is never perfectly still. Involuntary muscle micro-tremors in your hand produce movements of +/-1-3 pixels, every 50 to 200 milliseconds. It’s invisible to the eye. Your WAF’s JavaScript sensor counts them.
0 mouse movement for more than 2 seconds = “frozen cursor” flag. Signal sent to the server. Because a perfectly static cursor, in 2026, is the signature of a program. Not a human.
While testing, we bypassed this check in 20 minutes. A Gaussian micro-displacement around the current position, +/-1-3 pixels on X with a slightly wider sigma than Y (because a right-hander trembles more horizontally than vertically), every 50-200ms during pauses.
The WAF can’t tell the difference anymore.
And that’s the problem with behavioral detection: every human signal a WAF learns to measure is also a signal you can learn to simulate. The difference between a real tremor and a simulated one is a Gaussian parameter. Nothing else.
System fonts - the signal that holds
Your headless browser runs on Linux. It has around thirty fonts. Chrome on Windows has over 200.
Your WAF knows this.
The check: the JS sensor creates a canvas, writes text in Segoe UI at 72px, and measures the width with measureText(). If Segoe UI isn’t installed, the browser falls back to the default font. The WAF tests 4-5 Windows fonts (Segoe UI, Tahoma, Lucida Console, Calibri). If all widths are identical.. it’s a fallback. It’s a headless Linux.
What makes this signal very hard to bypass is that it’s physical. measureText() depends on the OS’s graphics rendering engine - FreeType on Linux, ClearType on Windows. Even installing Windows fonts on Linux, glyph widths differ by a few pixels because hinting and anti-aliasing aren’t the same.
The alternative is to override measureText() in JS to return the right values. Except you need to maintain a width table for every font x every size x every string the WAF might test. In practice, it’s a maintenance nightmare.
The bot can lie about its User-Agent. Its canvas. Its GPU. Its plugins. Its timezone. But it can’t lie about what its OS knows how to draw.
The structural limit
The limit of all static JS detection: you’re running your code on enemy ground. Their turf, their rules.
The toString checks filter noise. They’re a speed bump, not a wall. Behavioral checks can be bypassed with the right statistical parameters. System fonts hold longer because the signal is physical, not software.
Detection that holds plays out elsewhere:
- Server-side: the HTML/assets ratio in your access logs (a bot only loads the HTML).
- Network-side: the TCP/TLS fingerprint managed by the kernel, not the browser.
- Economic: making the bot too expensive to operate.
If your main line of defense relies on JavaScript, the only thing you’re testing is whether the attacker read the same blog post as you.
Usually, they did.
