Every mobile persona sets the same three things. navigator.maxTouchPoints becomes 5. (pointer: coarse) and (any-pointer: coarse) match. (hover: none) matches. A static probe reads those, agrees the device is a phone, and moves on.
Then the page adds an event listener, and a human moves the mouse.
pointerdown pointerType: "mouse" isTrusted: true
pointermove pointerType: "mouse" getCoalescedEvents().length: 1
mouseover / mouseout fire :hover styles apply
touchstart never fires, not once
The static surface says a device with no mouse and no hover. The event stream is a mouse with hover. Nothing in the identity layer produced those events; the browser’s input pipeline did, from real hardware, and the persona never touched the input pipeline.
How much of this a detector actually reads
Recording every Web API access at the engine level, over one session on a page running a commercial anti-bot SDK, the input surface dominated everything else:
| API | reads in one session |
|---|---|
PointerEvent.pointerType |
4853 |
Event.timeStamp |
1759 |
MouseEvent.clientX / clientY |
1279 |
PointerEvent.getCoalescedEvents() |
688 |
MouseEvent.isTrusted |
639 |
PointerEvent.isTrusted |
637 |
KeyboardEvent.getModifierState() |
70 |
InputEvent.inputType |
70 |
pointerType alone was read almost five thousand times. That is not a fingerprint being taken once at page load; that is a property being read on essentially every pointer event for the duration of the session. A device-class contradiction in that stream is not something a detector might notice. It is the thing it is watching.
Three separate contradictions
They are worth separating, because they have different fixes and different costs.
Device class. pointerType is "mouse" on a device that declared no fine pointer. Related: (hover: hover) behaviour appearing in practice while (hover: none) matches in the media query, mouseover/mouseout firing at all, and the complete absence of touchstart across a whole session on a device with five touch points.
Motion realism. This one is about automation rather than form factor. A synthesised click through a debugging protocol jumps: one pointermove at the target, or none, then pointerdown. Real pointer input arrives as a dense stream, and getCoalescedEvents() returns the samples the compositor merged into one frame - typically several, on a high-rate mouse or a touchscreen. A driver that dispatches one event per action returns a single coalesced sample every time, and Event.timeStamp deltas that are suspiciously regular.
Provenance. isTrusted is false for any event constructed and dispatched from JavaScript. Reading it 639 times in one session is a detector confirming that the interactions it is scoring were generated by the browser rather than by page script. This one is cheap to get right - drive input through the browser’s own input pipeline rather than dispatchEvent - and expensive to get wrong, because a false isTrusted is not a probabilistic signal.
Why the emulation path is different from the identity path
The interesting thing about the device-class half is that Chromium already contains the fix, and it is not in the renderer.
The browser process has a touch emulator that converts mouse input into touch events, living in the input handling layer of the render widget host. Everything needed to make a mouse-driven session produce a touch-shaped event stream is implemented, tested and shipped. Today the only thing that switches it on is the debugging protocol’s emulation domain.
That detail matters for anyone building this. There is a well-known argument against driving personas through the debugging protocol: it leaves traces in the renderer, and the emulated values end up fighting the configured ones. That argument is about the protocol, not about the emulator. Enabling the same emulator directly from the browser process, as a consequence of the persona’s device type, reuses the entire upstream implementation without the protocol ever being involved.
The general shape of the lesson: identity spoofing and behaviour emulation are different subsystems. A project that has built an excellent identity layer has not started on the second one, and the second one is where the input signals live.
What a real phone’s stream looks like
If you are going to emulate it, the target is worth stating precisely, because “send touch events instead” is not sufficient.
A real touch interaction produces pointerdown with pointerType: "touch", a non-zero width and height (the contact patch, typically tens of pixels, not the 1×1 a mouse reports), a pressure that is not the mouse’s flat 0.5, and a touches list on the corresponding touch events. It produces touchstart, touchmove, touchend alongside the pointer events. It produces compatibility mouse events after the touch sequence, not instead of it. It does not produce mouseover before the first contact, because there is no hover state to enter.
And the motion itself differs. Finger tracks are not straight lines and they do not stop instantly; they overshoot and settle. Sample rates differ from mouse rates, so the coalesced event counts differ. None of this is exotic to reproduce, but all of it has to be reproduced deliberately.
The related contradiction: hover
Touch gets the attention; hover is the same contradiction in a form that is harder to notice and easier to check.
(hover: hover) and (any-hover: hover) describe whether the primary and any input mechanism can hover over a target. A phone matches neither. A mobile persona sets them to none along with pointer: coarse, and then the page loads and the user’s mouse moves across it.
The mismatch surfaces in three places at once. CSS :hover rules apply, so any element that changes appearance on hover changes appearance, which is observable to script through getComputedStyle and through the paint. mouseover and mouseout fire, which they cannot on a device with no hover. And mousemove events arrive continuously between clicks rather than only in the moments around them, which is the shape of a mouse and not of a finger.
A detector does not need all three. A single mouseover on a device that declared any-hover: none is a contradiction between a media query and an event, delivered by the browser itself, with nothing inferred.
There is a fourth place worth mentioning because it is where careless emulation gets caught in the other direction. A device that genuinely has no hover produces no mouseover at all, but it does produce compatibility mouse events after a touch sequence completes. Suppressing mouse events entirely to look more like a phone produces a device that has neither hover nor touch compatibility events, which is not a phone either.
Scoring it
The static half of the contradiction is cheap to check and belongs in any scanner:
maxTouchPoints > 0while(any-pointer: coarse)does not match, or the reversemaxTouchPoints === 0on a browser presenting as mobilewindow.ontouchstartabsent on a device claiming touch - note this is a build fact, not a configuration one, and lands with the interface census- UA-CH
mobile: trueunder a desktop User-Agent string
The dynamic half needs the page to observe an interaction, which a fingerprint scanner run against about:blank cannot do. That asymmetry is worth being honest about: a scanner scores the static surface, and a live site with a session’s worth of events scores the stream. A persona can pass the first comprehensively and fail the second on the first mouse move.
Which is the reason to build both. The static contradictions are the ones you can fix in an afternoon and verify with a scanner. The event stream is the one that decides whether an account survives, and there is no way to check it except by producing input the way a device produces input.