A client gets rejected. The response is a generic string: try a different method, we could not verify this request, something went wrong. There are forty-odd signals in a browser fingerprint and any of them could be responsible, plus the network path, plus the behaviour, plus nothing at all - some rejections are not about the client.
The instinct at this point is to fix everything plausible at once. It is the wrong instinct, and the reason is not methodological purity. It is that when the batch of six fixes finally passes, you have learned nothing, you cannot tell which fix mattered, you are now carrying five changes of unknown value, and the next time it breaks you are back at the beginning with more surface area.
What follows is a worked bisect from a real investigation, including the part where four consecutive fixes changed nothing.
Fix your controls first
Before the first arm runs, two arms have to be defined that are not part of the experiment.
The negative control is a client you expect to be rejected, so you can tell that the endpoint is still rejecting rather than having quietly started accepting everything. Usually the unmodified baseline.
The positive control is a client you expect to pass. Stock browser, same host, same network path. If the positive control fails, the experiment is not measuring the fingerprint at all - the egress is being scored, or the flow changed - and every arm you were about to run would have produced a rejection and a wrong conclusion.
The positive control is the one people skip, and it is the one that saves the week. A day spent bisecting fingerprint dimensions against an endpoint that was rejecting your IP range is a day with a clean, confident, entirely false result at the end of it.
Hold everything you are not testing
The list of things that have to be identical across arms is longer than it looks: same physical host, same browser build, same driver and script, same network egress, same time of day, same page-load path. Changing the egress between arms because the previous one “felt slow” destroys the experiment silently.
The most easily broken constant is state. Restored tabs from a previous session, a warmed cache, a cookie jar from an earlier arm: all of these differ between arms unless explicitly controlled. Session restore is the classic offender, because it is on by default: a tab left over from an earlier run reopens alongside the page under test, contributing tens of thousands of unrelated API accesses to the log and presenting a browser that arrived already signed in somewhere else. Turn it off explicitly rather than assuming a fresh launch is fresh.
The arms
Same host, same driver, same network egress. One variable per arm. The endpoint returns either an acceptance or a refusal, so each arm has a machine-readable verdict.
| arm | changed | result |
|---|---|---|
| baseline | nothing (Windows persona on a macOS host) | rejected |
| 1 | six host leaks fixed in the browser build: window larger than screen, multi-monitor flag, GL extension table, WebGPU feature list, ICE candidate shape, heap limit | rejected |
| 2 | font masking switched off entirely, host fonts exposed | rejected |
| 3 | full captured WebGL surface from a real device of the claimed type | rejected |
| 4 | metric-compatible font substitutes registered | passed |
| control + | stock browser, no persona | passed |
| control − | original baseline, re-run after arm 4 | rejected |
Arms 1 through 3 each fixed something genuinely broken and none of them moved the verdict. That is the result people find hardest to accept, and it is the most valuable thing the method produces: it converts “we fixed a lot of things and it works now” into “one specific thing was the gate, and here is what it was”.
Note the re-run of the negative control at the end. Without it, an endpoint that softened during the session would look exactly like a successful fix.
Read the interrogation, do not guess at it
Bisecting blind costs one full arm per hypothesis, and there are forty hypotheses. The way to cut that down is to find out what the site actually reads before designing the arms.
If you can log API access at the engine level, you get the list directly and in order of weight. In the case above, the fingerprinting script called measureText 1011 times in a single session. That one number is what promoted fonts from “one of forty dimensions” to “the first thing to test”, and it is why the bisect found the answer in four arms instead of forty.
Without engine-level logging, the cheaper approximations still work: fetch the script and read it, or wrap the obvious entry points and count. The goal is not a complete inventory. It is a priority order, so the arms are spent on things the site demonstrably cares about.
What “changed one variable” has to mean
Two failure modes hide inside an arm that looks clean.
The first is a change with side effects. Switching font masking off does not only expose the host’s fonts; it changes the enumerable set, the metrics, and the OS the font set implies. That is three signals in one arm. It is still worth running, but the conclusion it supports is “the font dimension as a whole is not sufficient”, not “font enumeration specifically is fine”.
The second is a change that does not take effect. Setting an option that the code path silently drops produces an arm that is identical to the previous one while appearing to test something. The defence is to verify the change downstream of the setting rather than at it: read the configuration the browser actually received, or the header that actually went out, before counting the arm.
That failure is common enough to be worth a habit. A documented option that a wrapper layer silently drops before it reaches the browser is an ordinary bug, and the symptom it produces is not “my option did nothing” but “that browser feature is broken” - a conclusion people reach, write down, and act on for weeks.
After it passes
Two things are worth doing while the answer is still fresh.
Verify the mechanism, not just the outcome. Arm 4 passed, and the reason to believe it passed because of fonts is that the mechanism was measured directly: three specific families went from all returning one shared fallback width to returning three distinct widths. An arm that passes without a measured mechanism is a correlation, and correlations at these endpoints have a way of not reproducing next month.
Turn it into a gate. The finding is worth more as a test that runs on every build than as a paragraph in a document. The measurement that confirmed arm 4 - do the claimed platform’s font families have distinct metrics - is a five-line check. Once it is in the scanner, the class of failure it represents cannot come back silently, which is the only durable outcome a bisect can have.