We Replaced CAPTCHA with a Self-Hosted Proof-of-Work Challenge
Bots hammer every login form on the internet, including ours. The standard answer is a third-party CAPTCHA — and we decided against it. Here is what we built instead, and why a strict Content-Security-Policy forced a better design.
The problem with the standard answer
Credential stuffing is background radiation on the modern web: botnets replaying leaked email/password pairs against every login form they can find. Rate limiting by IP address helps, but a botnet spread across thousands of residential IPs stays politely under every per-IP threshold. You need something that makes each request cost the attacker more than it costs you.
The industry default is a hosted CAPTCHA — reCAPTCHA, hCaptcha, Turnstile. They work. But for us they came with three problems that compounded:
- They break a strict CSP. Our web app ships
script-src 'self'— no inline scripts, no third-party JavaScript, period. Every hosted CAPTCHA requires whitelisting an external script host and an iframe source. One widget would have re-opened the exact class of attack surface the policy exists to close. - They ship your visitors’ signals to someone else. A CAPTCHA vendor scores humanness from browser fingerprints and behavior. We operate in healthcare; the fewer third parties observing our users’ browsers on a login page, the better — and the shorter our vendor-review list stays.
- They add friction to real users. Even “invisible” CAPTCHAs sometimes escalate to picking traffic lights. Our admins and partners log in dozens of times a week.
Proof of work: make the request itself cost something
The alternative we shipped is an old idea with excellent modern fit: client puzzles, the same family of ideas behind Hashcash and the newer wave of privacy-first challenges like Friendly Captcha and mCaptcha. Before the login form submits, the browser must solve a small computational puzzle; the server verifies the solution in microseconds.
The flow:
1. Browser asks the server for a challenge
→ server returns { id, salt, difficulty } (single-use, short-lived)
2. Browser searches for a nonce such that
SHA-256(salt + ":" + nonce) starts with `difficulty` zero bits
→ a few hundred ms of silent hashing while the user types
3. Form submits with the challenge id + winning nonce
→ server re-computes ONE hash, checks the zero bits,
burns the challenge so it can never be replayed
The asymmetry is the whole trick. A legitimate user pays the cost once per login and never notices — the solve happens while they are typing their password. An attacker replaying 100,000 credential pairs pays it 100,000 times, on their own hardware. Combined with per-IP rate limiting, the two defenses cover each other’s blind spots: rate limits stop the single noisy attacker, proof of work taxes the distributed quiet one.
CSP-clean by construction
Because the solver is our own code served from our own origin, the strict CSP stays exactly as strict as before: script-src 'self', no exceptions, no iframe sources, no connect-src carve-outs. The whole challenge is a dependency-free script of plain JavaScript — including the SHA-256 implementation itself, chunked so the UI thread stays responsive during the search.
Server-side, verification is deliberately boring: recompute one hash, compare bits, and enforce that each challenge is single-use and expires in minutes — so a solved token cannot be stockpiled or replayed. A malformed or missing solution is rejected before any credential work happens, which means the expensive parts of login (database lookups, notification emails) are never reachable by unpaid traffic.
Bonus layer: the honeypot. Alongside the puzzle, the form carries a field that is invisible to humans but irresistible to naive form-filling bots. Any submission that fills it is rejected outright — a zero-cost filter that catches the dumbest third of automated traffic before the puzzle even matters.
What proof of work does not do
Honesty matters in security writing, so: this is bot resistance, not bot proof. A determined attacker with real hardware can still solve the puzzles — slowly, and at a compute cost proportional to their request volume. What the challenge changes is the economics: mass credential stuffing stops being free. For a targeted attack, the other layers — rate limits, lockouts, multi-factor authentication, and network-level access controls — are what stand in the way.
It also, unavoidably, requires JavaScript. For an authenticated developer portal that already requires a modern browser, that trade was easy; for a public content site it might not be.
When to choose this over a hosted CAPTCHA
- You ship a strict CSP and intend to keep it that way.
- You operate under privacy constraints (healthcare, finance) where “we send visitor telemetry to a CAPTCHA vendor” is a sentence you would rather not write in a security review.
- Your threat model is volumetric — credential stuffing and form spam — rather than sophisticated human-mimicking fraud.
- You want the failure mode to be yours: no third-party outage can lock your users out of your own login page.
If your threat model is dominated by paid human solving farms or advanced fraud, a hosted service’s ML signals genuinely earn their keep — that is the honest counter-case. For a partner login on a HIPAA-adjacent platform, the self-hosted puzzle plus layered rate limiting was the better fit, and it has the pleasant property of being about a day’s work to build end-to-end.
Security engineering, in the open
CRMBridge is a HIPAA-compliant API platform for dental and veterinary data — and we write up the engineering as we go. Read how we handle PII encryption and our HIPAA engineering checklist.