Building DropReady: Self-Hosted Proof-of-Work Against Queue Bots (Week 4)

Published on 17 September 2026

Building DropReady: Self-Hosted Proof-of-Work Against Queue Bots (Week 4)

Building DropReady: Self-Hosted Proof-of-Work Against Queue Bots (Week 4)

In Week 2 I covered Early Intercept and Ed25519 token verification, in Week 3 the first live integration and the new product page. This week closes a gap that had been sitting there since day one: there was no bot protection at all.

X-Queue-User was a random crypto.randomUUID() generated in the browser and sent as a header - the backend trusted it unconditionally. Rate limiting only counted by IP, so a bot rotating IPs and rotating UUIDs hit zero friction. Nothing stopped an automated script from minting as many queue slots as it wanted.

Why not a CAPTCHA

The first option on the table was Cloudflare Turnstile - free, no puzzle UX, well understood. I turned it down deliberately. DropReady's architecture is one client, one fully self-contained instance: its own VPS, its own Redis, its own license server. Wiring a third-party dependency into the core queue-join flow, for every tenant, even ones with no Cloudflare account, breaks that model.

Instead I built self-hosted proof-of-work - the same mechanism behind Hashcash, Anubis, and mCaptcha. It doesn't try to prove a visitor is human (that needs a third party). It raises the computational cost of minting a new queue identity, which is exactly what limits bots joining a drop.

How the challenge works

  1. GET /api/v1/queue/{tenantID}/challenge issues a short-lived (60s) EdDSA token carrying the tenant, a random jti, and the required difficulty (leading zero bits) baked in at issue time. Difficulty is read from the token, not re-checked live, so changing the difficulty preset mid-drop doesn't invalidate challenges already handed out.
  2. The widget computes SHA-256(token + nonce) in the browser via the Web Crypto API, incrementing the nonce until the hash has the required number of leading zero bits. The loop yields to the event loop every 200 iterations so higher difficulty slows down joining instead of freezing the tab.
  3. POST /join carries X-Challenge-Token and X-Challenge-Nonce. The backend verifies the token's signature, expiry, and tenant; confirms the hash actually meets the declared difficulty; and checks the jti hasn't been spent (Redis SETNX with a TTL matching the token's lifetime, so one solved challenge can't mint more than one identity).
  4. A second, tighter rate limit runs on X-Queue-User itself on /join, not just on IP.

The challenge is just another token type off the same TokenManager - same EdDSA key as entry tokens. No new secret to manage, no changes to a new client's deployment scripts. Difficulty is exposed in the admin panel as presets - Off/Low/Medium/High, mapped to 0/12/16/20 bits - instead of making an admin guess a sensible bit count.

The bug: fail-open vs. fail-rejection

The widget already had a fail-open mechanism: after 3 failed /join//status calls (say, the backend is briefly unreachable), it bypasses the queue entirely - deliberate, so an outage on our side never permanently blocks a store's customer.

If the new proof-of-work check had reused that same failure-counting path, a bot could have deliberately failed verification three times - a bad or expired challenge - and gotten the exact same bypass the feature exists to prevent. That would have quietly undone the whole point of building it.

The fix: two separate paths in the widget. handleError covers network/5xx failures, counter caps at 3, then bypass, because it's our backend that failed. handleRejection covers 4xx rejections from verification - bad, expired, or reused challenge, rate limit - cases where the backend answered correctly and simply declined this attempt. That second path never bypasses, it only retries with a fresh challenge, capped at 10 attempts so it can't loop forever without feedback.

Good reminder that a new security feature can silently reopen a hole in existing code if you don't trace every error path already sitting there.

Verified against a live backend

Real backend, real Redis, tested with curl:

  • GET /challenge returns a valid token with difficulty:0 - off by default for a fresh tenant.
  • POST /join without challenge headers returns 400 Missing challenge token/nonce - confirming the check actually gates the endpoint instead of sitting there unused.
  • POST /join with a correct solution returns 200 {"status":"joined","priority":false}.
  • Replaying the same challenge token returns 400 Invalid challenge solution - single-use holds.

FAQ

Why not just use a CAPTCHA like Cloudflare Turnstile?
Because DropReady runs as a dedicated, self-contained instance per client. A third-party CAPTCHA would add an external dependency to the core queue-join flow for every tenant, including ones without a Cloudflare account - that breaks the model.

Does proof-of-work slow down real customers?
At low or default difficulty the calculation takes a fraction of a second in the browser. Difficulty is configurable per client in the admin panel, so it can be tuned to the level of bot activity actually expected.

Why does the fail-open bypass matter here?
Because a bot could otherwise trigger the same "backend is down, let everyone through" bypass on purpose, by deliberately failing the proof-of-work check three times. Separating network failures from verification rejections closes that loophole.

DropReady

If you're running a store where drops or limited releases pull in bot traffic along with real customers - check out DropReady.

Michał Sobczak
Michał Sobczak https://msobczak.pl
Home About Us SaaS Blog Contact Get in Touch