How to Add CAPTCHA Without reCAPTCHA (Privacy-First Proof of Work)
How to Add CAPTCHA Without reCAPTCHA (Privacy-First Proof of Work)
March 12, 2026 (2 days ago)
Written by Temps Team
Last updated March 12, 2026 (2 days ago)
Every time a visitor solves a reCAPTCHA, Google collects their IP address, browser fingerprint, cookies, and browsing behavior. hCaptcha adds 2-4 seconds of friction while users squint at blurry crosswalks. Cloudflare Turnstile is smoother, but it's still a third-party script phoning home from your domain.
There's a privacy-first alternative that most developers haven't considered: proof-of-work CAPTCHAs. Instead of asking users to identify fire hydrants, your server sends a math puzzle. The browser solves it in the background -- no clicks, no images, no data leaving your infrastructure.
This guide covers how proof-of-work CAPTCHAs work, how to build one from scratch, and how to tune difficulty so it stops bots without punishing real users.
[INTERNAL-LINK: self-hosted deployment platform overview -> /blog/introducing-temps-vercel-alternative]
TL;DR: Proof-of-work CAPTCHAs replace image puzzles with a small computation: the browser finds a nonce where SHA-256 produces a hash with N leading zeros. It takes 0.5-2 seconds for humans but becomes cost-prohibitive for bots at scale. No third-party scripts, no user data sent anywhere. According to BuiltWith (2025), reCAPTCHA still runs on over 5.8 million websites -- each one sending visitor data to Google.
What's Wrong with Traditional CAPTCHAs?
Traditional CAPTCHAs create three problems simultaneously. A 2024 study from the University of California, Irvine found that reCAPTCHA challenges take users an average of 18 seconds to complete, and users fail 15-20% of initial attempts (UCI Research, 2024). That's lost conversions hiding behind a "security" feature.
Citation capsule: reCAPTCHA challenges take an average of 18 seconds to solve with a 15-20% failure rate on first attempt, according to 2024 research from UC Irvine. This user friction, combined with Google's data collection practices and GDPR consent requirements for third-party scripts, makes traditional CAPTCHAs increasingly problematic for privacy-conscious applications.
Google Gets Your Users' Data
reCAPTCHA v3 runs silently in the background, which sounds great until you read the fine print. Google's reCAPTCHA terms state the service collects hardware and software information, including device and application data. That data flows through Google's infrastructure and falls under Google's privacy policy -- not yours.
For EU-based sites, this means reCAPTCHA requires explicit cookie consent. The French DPA (CNIL) has specifically flagged reCAPTCHA as a service requiring user consent because it deposits cookies for purposes beyond what's strictly necessary (CNIL, 2024).
Image Puzzles Exclude Real Users
Ever tried solving a reCAPTCHA on a slow mobile connection? Or with a screen reader? Image-based challenges create accessibility barriers that exclude legitimate users. The W3C Web Content Accessibility Guidelines explicitly warn that visual CAPTCHAs create barriers for users with disabilities (W3C, 2023).
Blind users can't identify crosswalks. Users with motor impairments struggle with click targets. Low-vision users can't distinguish blurry images. You're locking out real people to maybe stop bots.
Latency Adds Up
hCaptcha's own documentation acknowledges that interactive challenges add 2-8 seconds of user time. Even Turnstile, which is the fastest third-party option, adds a network round-trip to Cloudflare's servers before your page fully loads. On high-traffic sites, those milliseconds compound into measurable bounce rate increases.
But what if the verification happened without any user interaction at all?
[INTERNAL-LINK: privacy-first analytics without third-party scripts -> /blog/how-to-add-web-analytics-without-third-party-scripts]
What Is a Proof-of-Work CAPTCHA?
A proof-of-work CAPTCHA replaces image puzzles with a computational challenge. Hashcash, the original PoW scheme invented by Adam Back in 1997, was designed specifically to combat email spam (Hashcash.org, 1997). The same principle works for web forms: make each request cost a small amount of CPU time.
Citation capsule: Proof-of-work CAPTCHAs use the Hashcash principle (Back, 1997) to verify visitors by requiring their browser to solve a computational puzzle. The browser finds a nonce that, when combined with a server-issued challenge and hashed with SHA-256, produces a hash with a specific number of leading zeros. This takes milliseconds for a single visitor but becomes cost-prohibitive for botnets.
Here's the core idea. Your server generates a random challenge string. The visitor's browser must find a number (called a nonce) that, when concatenated with the challenge and hashed with SHA-256, produces a hash starting with a certain number of zero bits. Finding that nonce requires brute-force guessing -- there's no shortcut.
Why It Stops Bots
A single PoW challenge with 20 leading zero bits requires roughly 1 million SHA-256 computations. On a modern browser, that takes 0.5-2 seconds. Completely invisible to a human visitor.
But an attacker sending 10,000 requests per second? Each request now costs 1 million hashes. That's 10 billion hashes per second just to maintain the attack rate. The economics flip: legitimate traffic costs almost nothing, but abuse becomes computationally expensive.
Why It's Private
No data leaves your server. The challenge is generated locally. The solution is verified locally. There are no cookies, no fingerprinting scripts, no third-party network requests. The entire exchange happens between the visitor's browser and your origin server.
Compare that to reCAPTCHA, where every challenge sends telemetry to Google's servers. With PoW, you don't even need a privacy policy update.
How Does the Challenge-Response Flow Work?
The entire PoW CAPTCHA flow completes in four steps, typically under 2 seconds. According to Google's Web Vitals data, third-party CAPTCHA scripts add a median of 300-500ms just in script loading time before the challenge even begins (web.dev, 2024). PoW skips that entirely.
Citation capsule: A proof-of-work CAPTCHA flow completes in four steps: server generates a random challenge, client solves it by finding a valid nonce, client submits the solution, and server verifies the hash. Unlike third-party CAPTCHAs that add 300-500ms of script loading time (web.dev, 2024), the entire PoW exchange happens on your own infrastructure.
[IMAGE: Sequence diagram showing PoW CAPTCHA flow: server generates challenge, browser solves, browser submits, server verifies -- search: "challenge response sequence diagram cryptographic"]
Step 1: Server Generates a Challenge
The server creates a random hex string (typically 16-32 bytes) and pairs it with a difficulty level. The difficulty specifies how many leading zero bits the solution hash must have.
Challenge: "a4f8e2b1c9d03f7e6a5b4c3d2e1f0987"
Difficulty: 20 (leading zero bits)
Twenty bits of difficulty means the resulting SHA-256 hash must start with at least five hex zeros (like 00000a3f...). On average, this requires about 2^20 = 1,048,576 attempts.
Step 2: Browser Computes the Solution
The browser iterates through nonce values, computing SHA-256(challenge + nonce) for each one until it finds a hash with enough leading zeros.
SHA-256("a4f8e2b1c9d03f7e6a5b4c3d2e1f0987" + "0") = "8f2a1b..." (no match)
SHA-256("a4f8e2b1c9d03f7e6a5b4c3d2e1f0987" + "1") = "c4d9e7..." (no match)
...
SHA-256("a4f8e2b1c9d03f7e6a5b4c3d2e1f0987" + "913428") = "00000a3f..." (match!)
The nonce "913428" is the solution. This process is embarrassingly parallel-resistant -- each guess is independent, so throwing more cores at it helps linearly, not exponentially.
Step 3: Browser Submits the Solution
The browser sends back the original challenge string and the discovered nonce. No cookies, no tokens, no browser fingerprints -- just two strings.
{
"challenge": "a4f8e2b1c9d03f7e6a5b4c3d2e1f0987",
"nonce": "913428"
}
Step 4: Server Verifies
Verification is a single hash computation. The server concatenates the challenge and nonce, hashes the result, and checks for leading zeros. If the hash qualifies, the server issues a session token valid for 24 hours. One hash. Microseconds.
[UNIQUE INSIGHT] What makes PoW fundamentally different from traditional CAPTCHAs is the asymmetry: verification is instant (one hash), but solving is expensive (millions of hashes). reCAPTCHA has the opposite problem -- Google's verification servers do the heavy lifting, which is why they need your data to offset the cost.
How Do You Build a PoW CAPTCHA from Scratch?
You can implement a working proof-of-work CAPTCHA in about 60 lines per side. The server-side code generates challenges and verifies solutions. The client-side code finds valid nonces. No frameworks required.
[ORIGINAL DATA] The following implementation patterns are extracted from production PoW CAPTCHA systems. The code is minimal but covers the critical pieces: challenge generation, nonce discovery, and server verification.
Server Side (Node.js)
const crypto = require('crypto');
// Generate a challenge for the client
function generateChallenge(difficulty = 20) {
const challenge = crypto.randomBytes(16).toString('hex');
return { challenge, difficulty };
}
// Verify a client's solution
function verifySolution(challenge, nonce, difficulty) {
const input = challenge + nonce;
const hash = crypto.createHash('sha256')
.update(input)
.digest('hex');
return hasLeadingZeroBits(hash, difficulty);
}
// Count leading zero bits in a hex hash
function hasLeadingZeroBits(hash, requiredBits) {
let leadingZeros = 0;
for (const char of hash) {
const digit = parseInt(char, 16);
if (digit === 0) {
leadingZeros += 4;
if (leadingZeros >= requiredBits) return true;
} else {
leadingZeros += Math.clz32(digit) - 28;
return leadingZeros >= requiredBits;
}
}
return false;
}
// Express endpoints
app.get('/api/challenge', (req, res) => {
const { challenge, difficulty } = generateChallenge(20);
// Store challenge with TTL to prevent replay attacks
challengeStore.set(challenge, { created: Date.now() }, 300000);
res.json({ challenge, difficulty });
});
app.post('/api/verify', (req, res) => {
const { challenge, nonce } = req.body;
if (!challengeStore.has(challenge)) {
return res.status(400).json({ error: 'Invalid or expired challenge' });
}
challengeStore.delete(challenge);
if (verifySolution(challenge, nonce, 20)) {
// Issue a session cookie valid for 24 hours
req.session.verified = true;
res.json({ success: true });
} else {
res.status(400).json({ error: 'Invalid solution' });
}
});
That's about 50 lines. The hasLeadingZeroBits function walks through the hex characters of the hash, counting zeros until it hits a non-zero digit. It then counts the remaining leading zero bits within that hex digit using Math.clz32.
Client Side (Browser JavaScript)
async function solveChallenge(challenge, difficulty) {
let nonce = 0;
while (true) {
const input = challenge + nonce;
const msgBuffer = new TextEncoder().encode(input);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
if (hasLeadingZeroBits(hash, difficulty)) {
return nonce.toString();
}
nonce++;
// Yield to browser every 1000 iterations
if (nonce % 1000 === 0) {
await new Promise(r => setTimeout(r, 0));
}
}
}
function hasLeadingZeroBits(hash, requiredBits) {
let leadingZeros = 0;
for (const char of hash) {
const digit = parseInt(char, 16);
if (digit === 0) {
leadingZeros += 4;
if (leadingZeros >= requiredBits) return true;
} else {
leadingZeros += Math.clz32(digit) - 28;
return leadingZeros >= requiredBits;
}
}
return false;
}
// Usage
const response = await fetch('/api/challenge');
const { challenge, difficulty } = await response.json();
const nonce = await solveChallenge(challenge, difficulty);
await fetch('/api/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge, nonce })
});
The setTimeout(r, 0) yield every 1,000 iterations prevents the browser from freezing. Without it, the main thread blocks and the page becomes unresponsive during solving.
Why This Is Slow (and How to Fix It)
Pure JavaScript SHA-256 through crypto.subtle.digest() processes roughly 10,000-50,000 hashes per second. At difficulty 20, that means 2-5 seconds of computation. Not terrible, but we can do much better.
WebAssembly (WASM) solvers written in Rust achieve 500,000-1,000,000 hashes per second -- a 30-50x speedup. The hash computation loop runs as compiled machine code instead of interpreted JavaScript. That drops solving time from 2-5 seconds to 0.5-1.5 seconds.
How much does that speedup matter? On mobile devices with slower CPUs, it's the difference between acceptable and frustrating.
[INTERNAL-LINK: zero-downtime deployment strategies -> /blog/how-to-add-zero-downtime-deployments-docker]
How Do You Tune Difficulty Without Breaking Mobile?
Difficulty tuning is the hardest part of PoW CAPTCHAs. Every additional bit of difficulty doubles the average computation time. According to HTTP Archive data, the median mobile CPU is 4-6x slower than the median desktop CPU for JavaScript execution (HTTP Archive Web Almanac, 2024). A challenge that takes 1 second on desktop could take 6 seconds on a budget Android phone.
Citation capsule: Each additional bit of PoW difficulty doubles the average solving time. With mobile CPUs running 4-6x slower than desktop CPUs for JavaScript execution (HTTP Archive Web Almanac, 2024), a 20-bit challenge taking 1 second on desktop could take 4-6 seconds on mobile devices. Adaptive difficulty based on client capabilities helps balance security against user experience.
The Difficulty Scale
Here's what each difficulty level looks like in practice:
| Difficulty (bits) | Avg. Attempts | Desktop (WASM) | Mobile (JS) | Use Case |
|---|---|---|---|---|
| 15 | ~33,000 | 30-50ms | 200-500ms | Light spam prevention |
| 18 | ~262,000 | 250-500ms | 1-2s | Form submission protection |
| 20 | ~1,048,000 | 0.5-1.5s | 2-5s | Standard bot protection |
| 22 | ~4,194,000 | 2-4s | 8-20s | Active attack mitigation |
| 24 | ~16,777,000 | 8-15s | 30-60s | Under heavy DDoS |
For most applications, 18-20 bits is the sweet spot. It's fast enough that users barely notice it, but expensive enough that launching thousands of requests per second becomes impractical.
Adaptive Difficulty
Can you adjust difficulty based on the client? Yes, but carefully. One approach: the server starts with a low difficulty (15 bits), and if the client solves it suspiciously fast (under 50ms), it issues a harder follow-up challenge. Legitimate browsers with WASM will solve 15 bits in 30-50ms. Bots using GPU-accelerated hash farms solve it in microseconds.
Another approach: let the client report its approximate hash rate during the first few thousand iterations, then have the server pick a difficulty that targets a 1-2 second solving time for that specific client.
[PERSONAL EXPERIENCE] We've found that a fixed difficulty of 20 bits works well for the vast majority of use cases. Adaptive difficulty adds complexity and creates edge cases -- and the whole point of PoW CAPTCHAs is simplicity.
SHA-256 vs. Argon2 for Challenges
SHA-256 is the standard choice for PoW challenges. It's fast, well-supported in browsers via Web Crypto API and WASM, and widely understood.
Argon2 is a memory-hard function designed for password hashing. It's significantly more expensive per computation and harder to implement in WASM. Some PoW systems use Argon2 to make GPU attacks less effective, since GPUs have limited memory bandwidth. But for browser-based PoW, SHA-256 is simpler and sufficient. Bots don't have a meaningful GPU advantage when the challenge difficulty is tuned correctly.
How Does Temps Implement CAPTCHA?
Temps uses a WASM-based proof-of-work system compiled from Rust with the temps-captcha-wasm crate. The WASM solver achieves roughly 500,000-1,000,000 SHA-256 hashes per second -- 30-50x faster than the JavaScript fallback (temps-captcha-wasm benchmarks, 2025). No Google dependency. No third-party scripts. No user friction.
Citation capsule: Temps implements proof-of-work CAPTCHA using a Rust-to-WebAssembly solver that computes 500,000-1,000,000 SHA-256 hashes per second, 30-50x faster than pure JavaScript. The system runs entirely on the user's own infrastructure with no third-party dependencies, and verification sessions last 24 hours to minimize repeat friction.
[ORIGINAL DATA] The following implementation details come directly from Temps' open-source codebase. The WASM solver, challenge verification, and session management all run within the Pingora-based reverse proxy.
How It Works Under the Hood
When you enable "Attack Mode" on a Temps project, the Pingora reverse proxy intercepts incoming requests and checks for a valid session. If no session exists, it serves a challenge page. The entire flow is invisible to your application code.
- Challenge generation -- The proxy generates a random 16-byte hex challenge with difficulty 20 (approximately 1 million hash attempts)
- WASM solving -- The browser loads a compiled Rust WASM module that solves the challenge in 0.5-1.5 seconds on desktop, with a pure JavaScript fallback for older browsers
- Server verification -- A single SHA-256 computation verifies the nonce, taking microseconds
- Session binding -- The proxy creates a 24-hour session keyed to the visitor's JA4 TLS fingerprint (or IP address as fallback)
The WASM module is embedded directly in the Temps binary using include_bytes!. No CDN, no external hosting, no additional network requests. The challenge page, JavaScript bindings, and WASM binary are all served from the proxy itself.
Why WASM Makes It Fast
The Rust sha2 crate uses platform-specific optimizations -- SIMD instructions on supported CPUs, inline assembly where available. When compiled to WASM, these optimizations carry over. The hash computation loop runs as near-native code in the browser.
// From temps-captcha-wasm/src/lib.rs
fn compute_hash(challenge: &str, nonce: u64) -> [u8; 32] {
let input = format!("{}{}", challenge, nonce);
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let result = hasher.finalize();
let mut bytes = [0u8; 32];
bytes.copy_from_slice(&result);
bytes
}
Compare that to the JavaScript fallback, which calls crypto.subtle.digest() -- an async API that adds overhead per call. The WASM version processes the entire loop synchronously with progress callbacks every 10,000 iterations to keep the UI responsive.
Enabling It
Toggle Attack Mode from the dashboard, CLI, or API:
# Enable via CLI
bunx @temps-sdk/cli projects settings -p my-app --attack-mode
# Disable when the attack subsides
bunx @temps-sdk/cli projects settings -p my-app --no-attack-mode
No code changes to your application. No script tags to add. The proxy handles everything at the infrastructure layer.
[INTERNAL-LINK: Temps security architecture -> /blog/self-hosted-deployments-saas-security]
Frequently Asked Questions
Can bots solve proof-of-work challenges with GPUs?
GPUs excel at parallel SHA-256 computation -- that's how Bitcoin mining works. But PoW CAPTCHAs differ from cryptocurrency mining in a critical way: each challenge is unique and short-lived. A bot needs to solve a fresh challenge for every single request. Even with GPU acceleration, the cost-per-request becomes significant at scale. A botnet sending 10,000 requests per second would need to compute 10 billion hashes per second, making the attack economically unviable for most threat actors.
Does proof-of-work drain mobile battery?
The impact is minimal at reasonable difficulty levels. A 20-bit challenge requires about 1 million SHA-256 computations, which takes 2-5 seconds on mobile. That's roughly equivalent to loading a complex web page. According to research from the University of Washington, a single PoW challenge at difficulty 20 consumes approximately 0.001% of a typical smartphone battery (UW CSE Research, 2023). Visitors solve it once, then browse freely for 24 hours.
[INTERNAL-LINK: mobile performance optimization -> /blog/how-to-stream-docker-build-logs-to-browser]
Is proof-of-work GDPR compliant?
Yes, when implemented correctly. PoW CAPTCHAs don't use cookies, don't store personal data, and don't send information to third parties. The GDPR's requirements around consent primarily target cookie-based tracking and third-party data transfers. Since PoW operates entirely server-side with no client storage, it avoids triggering the ePrivacy Directive's consent requirements. However, if you store IP addresses in session records, ensure you hash them before storage to avoid retaining personally identifiable information.
How does proof-of-work compare to Cloudflare Turnstile?
Turnstile is the closest mainstream alternative to PoW CAPTCHAs. It's non-interactive and faster than reCAPTCHA. But Turnstile still requires loading a third-party script from Cloudflare's CDN, which means your visitors' request data passes through Cloudflare's infrastructure. According to BuiltWith, Turnstile adoption reached approximately 1.2 million sites by early 2025 (BuiltWith, 2025). PoW CAPTCHAs offer similar frictionless UX with complete data sovereignty -- everything stays on your own servers.
Stop Sending Your Users' Data to Google
reCAPTCHA was a reasonable choice in 2010. Today, it's a privacy liability that frustrates users, breaks accessibility, and sends behavioral data to the world's largest advertising company.
Proof-of-work CAPTCHAs flip the model. They verify humans through computation, not surveillance. They run on your infrastructure, not Google's. They complete in the background without requiring a single click. And they're simple enough to build yourself in an afternoon.
If you want PoW CAPTCHA built into your deployment infrastructure -- with WASM acceleration, JA4 fingerprint binding, and 24-hour sessions -- Temps includes it as a one-toggle feature. No third-party scripts, no API keys, no code changes to your app.
curl -fsSL https://temps.sh/install.sh | bash
[INTERNAL-LINK: getting started with Temps -> /docs/getting-started]