1,011 text measurements: how a signup flow proves your browser is lying about its OS
One API call, a thousand times
Open a browser profile, go through a signup flow protected by Castle's SDK, and count the calls. In one registration we counted 1,011 calls to CanvasRenderingContext2D.measureText.
Not document.fonts.check. Not a font enumeration API. Not the queryLocalFonts permission prompt. A thousand measurements of how wide a string of text is.
That number is the whole article. Everything below is what it means and why it is the most awkward signal in the browser to fake.
Enumeration is a solved problem. Measurement is not.
Every anti-detect browser on the market can control which font families a page is allowed to see. Filter the enumerable set, block the odd API, and the naive detector - "you have Helvetica Neue and Menlo, therefore you are on a Mac" - goes quiet.
So the detectors stopped asking which fonts you have and started asking how wide they are.
The reason this works is not clever. It is how CSS has always resolved fonts:
ctx.font = '72px "Some Family"'
ctx.measureText('mmmmmmmmmmlli WWWW 0123456789').width
If Some Family is present, you get that family's metrics. If it is absent, the text renders in the fallback font and you get the fallback's metrics. There is no error, no exception, no undefined. "Not installed" is not a state you can hide - it is a specific number, and the detector knows that number, because it can produce it on demand by asking for a family nobody has ever installed:
ctx.font = '72px "Nsjkdfh Qwerty 4471"'
That is the baseline. Everything that matches the baseline is missing.
The comparison that decides it
Now put a persona in front of it. Your browser claims Windows: navigator.platform says Win32, the user-agent says Windows NT 10.0, the client hints say Windows, the GPU strings name a Direct3D adapter.
A real Windows machine ships Segoe UI, Calibri and Cambria. They are different typefaces with different metrics, so the same string measured in each gives three different widths - and none of them equals the baseline.
On a machine that is not Windows, none of the three exists. All three fall back. All three measure at the baseline. Here is what that looks like in practice, at 72px:
| Family requested | Windows persona, non-Windows machine | A machine that really is Windows |
|---|---|---|
| Segoe UI | 1287.88 | one value |
| Calibri | 1287.88 | a different value |
| Cambria | 1287.88 | a third value |
| A family nobody has | 1287.88 | 1287.88, and nothing else matches it |
The left column is a contradiction with no innocent explanation. There is no Windows install anywhere on earth where Segoe UI measures exactly the same as a font that does not exist. One comparison, no machine learning, no risk score - the profile said Windows and the rendering engine says otherwise.
And because this is arithmetic rather than a heuristic, it does not care how good the rest of your fingerprint is. We ran the whole ladder: aged profiles, clean residential exits, several different IPs, real human hands on the keyboard instead of automation. All rejected. Fix the metrics, and the same flow completes.
It is worse on mobile personas
The Windows case at least fails loudly on three named families. A mobile persona running on a desktop machine fails on something more fundamental.
CSS has three generic families that every browser must resolve to something: sans-serif, serif, monospace. On a real Android device they resolve to three different typefaces, so they measure at three different widths. That is not a fingerprinting artefact, it is just what a phone is.
Run an Android persona on a desktop host without the Android font corpus and all three resolve to the same fallback - one width, three times. A detector does not even need to know which fonts Android ships. It only needs to know that serif and monospace are never the same width on a real phone.
This is the same failure as the Windows one, one level lower in the stack, and it is why "which OS am I claiming" cannot be a cosmetic setting.
Fonts are not the only thing Castle reads
For completeness, because the font signal is the one that got our attention but not the only one being collected. In the same registration:
PointerEvent.pointerTyperead 4,853 timesEvent.timeStampread 1,759 timesMouseEvent.clientX/clientYread 1,279 timesgetCoalescedEvents()called 688 timesisTrustedread 639 times
That is a behavioural profile: what kind of input device you are, how your pointer moves between two clicks, whether the intermediate positions a real mouse generates are there at all. A driver that jumps the cursor from element to element and types at machine speed has nothing to put in those fields.
We want to be precise about the evidence, though: in our tests the behavioural surface was not what caused the rejections. Sessions driven entirely by a human hand were rejected the same way automated ones were, and the same profiles passed once the metrics were consistent. Behaviour is a scoring input; the font contradiction was a hard fail.
What this means if you are building on top of a browser
Three things follow, and none of them are specific to one vendor's SDK.
A claim you cannot render is a claim you should not make. Strings are free - anyone can set a user-agent. Metrics are not: they come from actual font files being actually rasterised. If your stack says Windows, the Windows text metrics have to be there, for every family a Windows machine has. If they are not, you have not disguised the machine, you have labelled it.
Subtracting is not adding. Every allow-list, block-list and enumeration filter can only take fonts away. That is genuinely useful - it keeps host-only families out of view - but no filter has ever conjured a font that is not on the disk. If your only tool is a filter, the OS you claim must be the OS you are running.
Do not use document.fonts.check to test any of this. It returns true for essentially any family you pass it, including names you invent on the spot, because it answers "can this be rendered" - and everything can be rendered, via fallback. It is the single most common way people convince themselves their font surface is fine when it is not. Measure widths, or measure offsetWidth on a span. Nothing else counts.
Check your own browser in thirty seconds
This runs anywhere, needs no library and no permission:
const ctx = document.createElement('canvas').getContext('2d')
const sample = 'mmmmmmmmmmlli WWWW 0123456789'
const width = (family) => {
ctx.font = `72px "${family}"`
return ctx.measureText(sample).width
}
const baseline = width('Nsjkdfh Qwerty 4471') // a family nobody has
const claimed = ['Segoe UI', 'Calibri', 'Cambria'].map(width)
console.log({ baseline, claimed })
// Windows persona: all three must differ from baseline, and from each other.
// sans-serif / serif / monospace: three different numbers, always.
If your browser claims Windows and prints the same number four times, that is what the signup flow saw.
Where we stand
Our engine handles this at the rendering layer rather than with a script that patches measureText after the fact, for the same reason we do proxy authentication in the network stack instead of an extension: anything a page can read, a page can compare against something else it can read. We are not going to walk through the implementation here.
What we will say is the part that is useful to you either way: pick the persona OS deliberately, know whether your browser can actually produce that OS's text metrics on the machine you are running, and test it by measuring rather than by asking. That is the whole gate.
Try it on the free tier.
Unlimited local profiles, no credit card. Check it against the detectors yourself.