Back to guides
Security · Measured

67,751 Challenges, Not One Attempt.

When a browser proof-of-work challenge is not slowing an attacker down, the obvious lever is the difficulty: add bits. It is the wrong lever, and the reason is arithmetic that runs the opposite way to intuition. Measured in the browser's own solver, a compute-bound challenge at 26 bits costs a real user 25.9 s. A memory-hard challenge at 4 bits costs 620 ms.

The lower number is the harder challenge. This article is the measurement behind that sentence, plus a twelve-minute flood that arrived while it was being written and supplied the other half of the evidence - along with a clear statement of what that flood does not prove.

By Ozy-666, creator and operator of dnsdoh.art · Published · 13 min read
Where the cost lives COMPUTE-BOUND · SHA-256 Cost of one probe 0.3862 µs so you need a lot of them Probes at 26 bits (mean) 67,108,864 What a real user waits 25.9 seconds nobody waits this out MEMORY-HARD · ARGON2ID Cost of one probe 71.2 ms so you need very few Probes at 4 bits (mean) 16 What a real user waits 620 ms median of 15 full solves One memory-hard probe costs about 184,000 compute probes WHAT EACH ONE HANDS THE ATTACKER Parallel hashing is cheap Rented GPUs run thousands of lanes; SHA silicon is a commodity part. Raising bits punishes phones before it hurts a farm. Memory is the bottleneck Every parallel lane needs its own block of RAM, so concurrency is capped by memory, not core count, and bandwidth saturates.

Both schemes ship to the browser as WebAssembly. The choice is not the delivery vehicle, it is which resource the puzzle charges.

A proof-of-work challenge is symmetric: the client and one attacker node do the same work. So difficulty is capped by your slowest legitimate device, never by what would stop a bot. The only lever that moves the attacker's cost without moving yours is the work function itself.

"WebAssembly versus Argon2id" is a category error

This comparison is usually framed as WASM against Argon2id, and that framing is confused before the argument starts. WebAssembly is how the solver reaches the browser. Argon2id is what the solver computes. Both of the schemes below ship as WebAssembly - the Argon2id one is a compiled WASM build of the reference algorithm, delivered by the widely used hash-wasm library.

The real axis is which resource the puzzle bills:

  • Compute-bound. Search for a nonce whose SHA-256 digest has N leading zero bits. Each probe is one cheap hash. The cost is CPU cycles, and it scales with how many probes you demand.
  • Memory-hard. The same leading-zero search, but each probe is one full Argon2id evaluation, which pins a large block of RAM and walks it several times. The cost is memory bandwidth and capacity, and it is already present in a single probe.

Everything that follows comes from that one difference. The parameters I used are Argon2id at about 19 MiB with two passes and one lane, which is roughly what a browser challenge can afford without spinning a low-end phone. They are illustrative figures chosen for the benchmark, not a description of anyone's deployment, and RFC 9106 gives its own recommended parameter sets for the general case.

What one probe costs

Every number in this article was measured on 7 August 2026, on the machine that serves this site, using the same solver code a browser runs: a compiled WebAssembly SHA-256 searcher, and the hash-wasm Argon2id build. Running both under the same WebAssembly engine matters - comparing a browser-side scheme against a native-Go baseline would flatter one side by a factor of two or more.

Work function Cost per probe Method
SHA-2560.3862 µsmedian of 10 runs x 2,000,000 probes
Argon2id, 19 MiB, t=2, p=171.2 msmedian of 15 (min 61.5, max 93.1)
Argon2id, 16 MiB, t=2, p=160.7 msmedian of 15 (min 51.7, max 72.6)

That is a ratio of about 184,000 to one. It is the entire argument, and everything else is a consequence of it.

A measurement I threw away. My first SHA-256 figure was 35 µs per probe, taken through the browser's crypto.subtle.digest API. That number is nearly all promise-dispatch overhead, not hashing: it times one asynchronous call per probe, while a real solver runs a tight loop of hundreds of thousands of probes inside a single WebAssembly call and never returns to JavaScript.

Published, it would have understated the compute scheme's efficiency by a factor of ninety and made the comparison look far more favourable to memory-hardness than it is. The 0.3862 µs figure comes from the compiled searcher doing two million probes per call.

Why the bit counts run backwards

A leading-zero search at N bits needs 2N probes on average. Because a compute probe is nearly free, a compute-bound scheme has to demand an enormous number of them before the total is worth anything. Because a memory-hard probe is already expensive, a handful is enough.

So the difficulty settings look inverted. The memory-hard tier runs at two to four bits. The compute tier needs twenty or more to impose comparable friction. That is not weakness on the memory-hard side - it is where the cost sits. In Argon2id the cost is in each probe, not in the count, and every extra bit still doubles the expected number of probes.

Here is the compute side, computed from the measured per-probe cost:

Difficulty Mean probes Time
14 bits16,3846 ms
16 bits65,53625 ms
20 bits1,048,576405 ms
26 bits67,108,86425.9 s

And here is the memory-hard side, where each row is fifteen complete searches rather than arithmetic:

Difficulty Median Fastest Slowest Median probes
2 bits199 ms66 ms702 ms3
3 bits372 ms73 ms1,092 ms5
4 bits620 ms77 ms6,960 ms9

Median probe counts sit below 2N because the number of trials is geometrically distributed: at two bits the mean is four but the median is three. Quote the median for what a person experiences and the mean for cost arithmetic, and say which one you are using.

The tail nobody quotes

At four bits the median solve was 620 ms. The slowest of the same fifteen runs took 6,960 ms. That is not measurement noise and it is not a slow machine - it is the shape of the distribution. A leading-zero search has no upper bound, and a geometric tail means a minority of perfectly legitimate users will wait an order of magnitude longer than the median.

Any honest description of a proof-of-work challenge has to carry that number next to the median. "Sub-second" is true for most people at four bits and false for some of them, every time, forever. Publishing only the median is how a challenge that occasionally strands users gets described as invisible.

It also sets a practical ceiling that has nothing to do with attackers: each additional bit doubles both the median and the tail. The tail reaches unacceptable before the median does.

Why 26 bits is not a harder challenge, only a crueller one

Both schemes are symmetric. Your client and one attacker node run the same search. So the maximum difficulty you can set is bounded by your slowest legitimate device, and never by what would actually deter a bot. That is the trap in the obvious lever.

Follow the compute curve up and watch it break. At 20 bits a real user waits 405 ms, which is defensible. At 26 bits the same user waits 25.9 seconds, which nobody does - they close the tab. Meanwhile a machine renting parallel hashing capacity has barely noticed, because SHA-256 is the single most parallelised operation in computing: thousands of lanes on a commodity graphics card, and dedicated silicon sold by the shelf. Push the difficulty high enough to inconvenience that, and you have already locked out every phone you serve.

Memory-hardness changes which curve moves. Each parallel Argon2id lane needs its own block of RAM, so an attacker's concurrency is capped by memory capacity divided by block size rather than by core count, and memory bandwidth saturates long before the cores do. Solving many in parallel makes each one slower, because the lanes contend for the same bus. The hardware advantage that made the compute puzzle cheap is the thing being charged for.

There is one more reason this matters, and it is the one that is easy to miss. TLS and JA4 fingerprinting works well until an adversary drives a real browser engine, at which point the fingerprint is a real browser's fingerprint because it is one. A memory-hard cost does not care what the handshake looked like. It bills the hardware, which is where the asymmetry actually lives.

A flood arrived while this was being written

On the evening of 7 August 2026 this site took a distributed flood lasting 12 minutes and 20 seconds. It is a small event by any external standard, and that is part of why it is useful: it is fully captured rather than reconstructed.

Measure Value
Challenges issued67,751
Issued as memory-hard99.9%
Issue rate, median over 42 intervals93.6/s (peak 313.6/s)
Distinct source addresses923
Distinct networks (ASNs)525, across 97 countries
Networks contributing one address only387
Tracked outcomes, all unsolved1,287
Incorrect proofs submitted0

The shape is worth as much as the totals. 387 of 525 networks contributed exactly one address, spread across 97 countries and mixing cloud providers with residential telcos. There is no subnet to block and no coherent fingerprint to match - which is precisely the case where the memory-hard argument has to carry the load on its own.

What this does not show. It is tempting to write that the botnet could not solve the challenge. Nothing here supports that. The count of incorrect proofs was zero, which means not one of those 923 addresses ever fetched the solver, ran it, and submitted a wrong answer. They did not fail the work - as far as the evidence goes, none of them attempted it.

The defensible statement is narrower and, I think, more interesting: they were handed a cost their tooling would not even try to pay, and every one of them walked away instead. Whether that is because the cost was prohibitive or because the tooling simply has no solver in it, this data cannot say.

Two controls run against that same window. The verifier never saturated once, because verifying a solve is a single evaluation regardless of how long the search took - the asymmetry that makes this affordable to run. And while the flood was live, both a desktop and a Samsung S21 Ultra on 5G were served the same memory-hard challenge deliberately and solved it on the first attempt, fast enough that neither felt like waiting.

Where each one belongs

These are complements, not rivals. The useful split is breadth against depth.

Situation Work function Reasoning
Ordinary trafficnonezero friction for the overwhelming majority
Mild suspicioncompute, low bitscheap, finely gradable, and invisible to everyone else
Scored suspicioncompute, scaled upper-client difficulty; the server stays cheap under a spray
Active, resourced attackmemory-hard, low bitsthe adversary has hardware, so charge the resource it cannot rent cheaply
Endpoints a human needs mid-attackeither, capped lowa diagnostic page has to stay solvable exactly when things are broken

Two things belong on the memory-hard side specifically. The parameters must be fixed by the server and carried in a signed token, never accepted from the client - if the client can negotiate the memory size down, the memory-hardness is optional and therefore absent. And the payload is larger, so the solver wants to be cached before it is needed rather than fetched at the moment of an attack.

The failure mode to design around is not a bot getting through. It is walling off people who cannot participate at all: clients without JavaScript, assistive technology, and machine clients like resolvers, which legitimately send no user agent and cannot run a WASM challenge. A proof of work is one tier among several, and treating it as a gate on everything blocks real traffic in the name of stopping fake traffic. We have made a version of that mistake before, when a blocklist update started banning search-engine crawlers that were behaving exactly as they should.

Measure it yourself in two minutes

Nothing here needs our stack. The Argon2id side needs only the public hash-wasm package and Node 18 or newer, and it reproduces the per-probe cost and the full-solve distribution in the same run.

# argon-bench.mjs - one probe, then a full leading-zero search
import { argon2id } from 'hash-wasm';

const SALT = new TextEncoder().encode('example-salt-value');
const P = { salt: SALT, parallelism: 1, iterations: 2,
            memorySize: 19456, hashLength: 32, outputType: 'binary' };

// leading zero bits of a byte array
const lz = a => { let n = 0;
  for (const b of a) { if (b === 0) { n += 8; continue; }
    for (let j = 7; j >= 0; j--) { if (((b >> j) & 1) === 0) n++; else return n; } }
  return n; };

// cost of a single probe - this is the whole point
let t = process.hrtime.bigint();
await argon2id({ ...P, password: 'token:0' });
console.log('one probe', Number(process.hrtime.bigint() - t) / 1e6, 'ms');

// a full solve at 4 bits - expect ~16 probes on average, and a long tail
t = process.hrtime.bigint();
for (let nonce = 0; ; nonce++) {
  const h = await argon2id({ ...P, password: `token:${nonce}` });
  if (lz(h) >= 4) { console.log('solved', nonce + 1, 'probes',
      Number(process.hrtime.bigint() - t) / 1e6, 'ms'); break; }
}

Run the solve loop fifteen times before believing any single figure. One run tells you almost nothing here - the distribution is geometric and individual runs land anywhere from one probe to fifty.

Verification is the other half, and it is the part that makes this affordable to operate: recompute the one hash for the submitted nonce, check the leading-zero count against the difficulty you issued, and confirm that the difficulty and parameters came from your own signed token rather than from the request. One evaluation, constant time in the size of the search.

These measurements are published under CC BY 4.0. Reuse the numbers and the tables freely, including commercially, as long as you credit dnsdoh.art and link back to this page.

The general lesson

When a symmetric defence is not working, the instinct is to turn it up. But a symmetric cost has a ceiling set by your weakest legitimate user, and turning it up walks you toward that ceiling faster than it walks the attacker toward theirs. Every doubling costs a phone more than it costs a rented server, because the phone was closer to giving up when you started.

The lever that works is not the magnitude, it is what you are charging for. Move the cost onto a resource where the attacker's advantage is smallest - here, memory bandwidth instead of hash throughput - and the same wall-clock friction for a real person becomes a genuinely different proposition for a farm. That reframing generalises well past proof of work: whenever a control is symmetric, look for the axis on which it stops being symmetric, rather than for a bigger number on the axis you have.

And keep the discipline about what the evidence supports. This site handed out 67,751 challenges in twelve minutes and received zero wrong answers. The satisfying conclusion is that the wall held. The supportable one is that nobody tried to climb it - which is a good outcome, and a different claim, and the difference is the sort of thing that turns out to matter later.

The measurement scripts

Both benchmarks in this article are a few dozen lines against public libraries, and the listing above is the whole Argon2id one. There is nothing proprietary in either - the interesting part is the ratio they produce, not the code that produces it.

Set up encrypted DNS

Related reading: how an open resolver becomes an amplification weapon, when our blocklist banned Googlebot, keeping admin interfaces off the public internet entirely, and an anti-DDoS setting that refused every POST for five weeks.

Ozy-666 Author · Creator of dnsdoh.art

Ozy-666 builds and operates dnsdoh.art, an encrypted DNS resolver serving DoH, DoH3, DoT and DoQ. Every figure in this article was measured on that machine on 7 August 2026: the per-probe costs and full-solve distributions were benchmarked in the same WebAssembly solvers the browser runs, and the flood figures were counted from the challenge gateway's own counters and outcome log during a 12 minute 20 second event that began at 18:51 local time. The desktop and S21 Ultra solves described at the end were run deliberately, on the same evening, against the live service.