News3 hours ago

Anubis Shipped Memory Hard Proof of Work and Left It Switched Off

A year of work, one LLVM bug, and 571 lines of Rust later, Anubis can now make bot challenges memory hard. It ships disabled, and the tests will not run from a clean clone.

The WJS Desk

Sep 8, 2026 · 8 min read

Photo by Canio Tiri on Pexels

We went to read the announcement post for Anubis v1.28.0 and Anubis stopped us. The blog lives behind the thing it is announcing, so our first attempt at the primary source came back as an interstitial reading Making sure you're not a bot!, served by v1.28.0-pre1.0.20260906214259-7564aa1d8a85 with a metarefresh challenge at difficulty 1. Fair enough. We read the post out of the repository instead, where it lives as 362 lines of MDX added in pull request #1941.

What it announces is real and it is bigger than the changelog line suggests. Anubis, the 22,225 star Go reverse proxy that makes AI crawlers do arithmetic before they can read your git web interface, can now run its proof of work as WebAssembly compiled from Rust. That means challenges can be memory hard instead of just CPU hard. It took a year, and it ships turned off.

What actually shipped

v1.28.0-pre1, tagged Wuk Lamat, went out on 30 August 2026. It registers three new challenge algorithms next to the existing fast and metarefresh ones. We found them in lib/challenge/wasm/wasm.go:

chall.Register("argon2id", &Impl{algorithm: "argon2id"})
chall.Register("hashx", &Impl{algorithm: "hashx"})
chall.Register("sha256", &Impl{algorithm: "sha256"})

An administrator opts in per threshold or per bot rule, which in practice looks like this:

thresholds:
  - name: moderate-suspicion
    expression:
      all:
        - weight >= 10
        - weight < 20
    action: CHALLENGE
    challenge:
      algorithm: argon2id
      difficulty: 6

None of that is on by default, and we checked rather than taking the release notes at face value. The shipped data/botPolicies.yaml still names only metarefresh and fast. The release notes say why: the maintainer is not certain it works out of the box for every edge case, and plans to make it the default in v1.29.0 once administrators have reported back.

The other change buried in here fixes a scaling bug that has annoyed operators for a year. The old fast challenge counted leading zero nibbles in a hash using string comparison, so adding 1 to the difficulty made a challenge sixteen times harder. The WASM challenges count bits. The release notes ship a translation table, which is the number administrators actually need:

fast difficultyargon2idsha256hashx
2287
441615
662420

If you copy your old difficulty number into a sha256 rule, you have just made your site roughly four times easier to get past. Read the table.

571 lines of Rust and 25.5 MB of build tools

We cloned the repository to see what a year of work looks like on disk. The answer is smaller than expected. Four crates, 571 lines of Rust in total: 60 in wasm/anubis, 172 in the hashx implementation, 171 in argon2id, 168 in sha256. All of it no-std targeting wasm32-unknown-unknown, which the author says produces the smallest fast WebAssembly he could get.

The bulk of the year went into the build, not the code, and the evidence is sitting in the repo. Two files under utils/wasm/wasm2js/ are compiled copies of Binaryen's own tools, shipped as WebAssembly so that every build uses byte-identical tooling:

Committed artefactSize
wasm-opt_130.wasm13,731,182 bytes
wasm2js_130.wasm12,997,355 bytes
Rust source for all three challenges571 lines

That is 25.5 MB of binary in git to protect a few hundred lines of source. The post's justification is blunt and hard to argue with: your build tools cannot differ from his build tools if he ships you his build tools. The version numbering is worth noting for anyone reading along: the post describes settling on Binaryen 128, and the binaries in the repository today are labelled 130.

The reason he needed that determinism at all is the best story in the post. Rust enables reference types by default for wasm32-unknown-unknown, which makes LLVM encode call_indirect table indices in a way Chrome 75 refuses to compile. Chasing that led to a genuine LLVM bug where each build drifted by about 29 bytes, and the tell was that disabling address space randomisation with setarch --addr-no-randomize made the output consistent again. It is filed as llvm-project issue 204883, and it is the first compiler bug of the author's career.

One detail in the build script that the blog post does not spell out: the round trip through wasm-opt runs with no optimisation passes at all. The comment in wasm/scripts/build_wasm.sh gives two reasons, and the second one is the interesting one. The WebAssembly build of wasm-opt exhausts its stack on -O2, and they would rather not have an optimiser rewriting proof of work code in the first place.

What we could and could not run

We tried to exercise the new challenges ourselves. This is where we have to be precise about what we did, because the result is a useful warning rather than a benchmark.

On a clean shallow clone, on macOS arm64 with Go 1.25.7, go test ./lib/challenge/wasm/... -v does not pass. Two of the three algorithms fail immediately:

--- FAIL: TestValidateAdversarial/sha256
    wasm_test.go:88: can't set up sha256 challenge:
    open static/wasm/simd128/sha256.wasm: file does not exist
--- SKIP: TestValidateAdversarial/argon2id
    wasm_test.go:86: skipping argon2id on aarch64 because
    validation is slow in the interpreter

The first two are our fault, not a bug: the .wasm modules are generated, not committed, and the Makefile builds them by running wasm/scripts/build_wasm.sh. That needs the Rust toolchain plus wasmtime or Binaryen from the project's own Brewfile, and we had no Rust installed. So we did not measure solve times and we are not going to pretend otherwise. The whole run, module downloads included, took 37 seconds to tell us that.

The one to actually worry about: the argon2id skip is in the committed test file, twice, and it is not about our machine being unusual. Server side validation of argon2id is slow enough under the WebAssembly interpreter on aarch64 that the project skips testing it there. If you run Anubis on Graviton or Ampere, that is your CPU, and validation is the part that runs on your server for every solved challenge.

For payload size we have to attribute rather than measure. In the Hacker News thread, the maintainer put the WebAssembly download at 32 to 40 KiB before compression, and called it negligible after.

The thread found the actual objection

The post hit 302 points and 145 comments on Hacker News and 71 points on Lobsters. The praise is easy to find, so here is the dissent.

The post claims the "hey Claude vibeslop me a CUDA Anubis solver" route is on its way to being fundamentally dead. kingstnap on Hacker News was not convinced: "Lmao yeah no. I don't think a little argon2 is going to change shit all." lxgr made the sharper version of the same point, that this mostly pushes scraper compute onto consumer devices, and added that "Anubis is ironically speedrunning a lot of discoveries the crypto folks have already made several years ago."

They have evidence on their side. Retr0id linked two solvers that already exist: pow-buster, a Rust SIMD solver that describes itself as adversarially implemented against Anubis, mCaptcha and Cerberus, and anubis_webgpu, which was created on 18 August 2026. Neither is popular (16 stars and 4 stars respectively) but both are real, and the WebGPU one predates this release by twelve days.

The best technical comment was on Lobsters, from vpr, explaining why the obvious choice of RandomX would be wrong here: it "has too big an asymmetry for mobile vs desktop, harder to tune for low memory targets, and finally is easy to accelerate without specialized hardware", so it "would be easier for the adversaries mining with FFI than honest parties in the browser." That is the argument for argon2id stated better than the release notes state it.

And the comment nobody boosted, one point on Lobsters, from classichasclass: the JavaScript fallback is not only for people who disable WebAssembly, it is needed "for those minority architectures that don't have browser JITs yet." That fallback works by compiling the WebAssembly back to JavaScript with wasm2js, which is exactly as cursed as it sounds and comes with a known regression: the progress bar does not move while it runs.

Two Hacker News replies also suggested the year could have been shorter. Georgelemental pointed at Rust's wasm32v1-none target, which gives baseline WebAssembly with no extra target features, and evmar named build-std as the flag for recompiling the standard library. Both would have addressed the problem that sent the author down the vendored-tooling path. Neither is free, and wasm32v1-none restricts you to no-std, which this code already is.

Why this release is not about argon2id

Take the skeptics at their word and assume the solvers keep up. The release still matters, because of a sentence in the post that has nothing to do with hashing: breaking Anubis from a monolithic binary into what amounts to a plugin loader. The stated goal is pulling challenge modules from an OCI registry so that challenge logic can change as soon as scraper behaviour changes, without recompiling and redeploying a Go binary everywhere.

The client and the server run the same binary, not the same code. That is the shipment. Argon2id is the demo.

If that lands, the arms race stops being one where a solver has weeks to catch a new algorithm. Whether a solo maintainer working what he calls two full time jobs can sustain a runtime module distribution system is a different question, and the honest answer is that nobody knows yet.

One last detail we appreciated, because it is the sort of thing this site cares about. The post carries a note that AI was not used to write its prose, with a link to the draft's revision history as proof, and the only declared use is Claude Opus for the bit versus nibble diagram. Then a commenter, omoikane, caught an arithmetic error in the post: a claim that one difficulty step made challenges 1,024 times harder when the nibble to bit difference makes it 16. The author's reply was "Oops, there's your proof I didn't write this with AI!" and he fixed it. We will take a corrected human error over a smooth wrong number any day.

Share

A year of work on Anubis WASM proof of work produced 571 lines of Rust and 25.5 MB of build tools committed to git. It ships switched off. #WebAssembly #Rust #OpenSource #AICrawlers

Never miss a ship

The best stuff that shipped this week, delivered every Thursday. Free, no spam. We read all the boring stuff so you get the fun parts.

Keep reading