Tech Deep Dive

Web Crypto API: Implementing Proof-of-Work in JavaScript

· 11 min read

Over 100 million spam messages hit contact forms every single day. Most of them cost the attacker nothing. A bot can fill out and submit a form in under 50 milliseconds, and it can do it thousands of times per minute from a single machine. The asymmetry is brutal: submitting spam is nearly free, but dealing with it costs server resources, storage, bandwidth, and human attention.

What if you could flip that equation? What if every form submission required the browser to burn a few hundred milliseconds of CPU time before the server would even look at it?

That is the core idea behind client-side Proof of Work (PoW). It is the same concept that powered Bitcoin’s consensus mechanism, adapted for a much simpler purpose: making spam expensive.

This tutorial walks through a working implementation using the Web Crypto API, the browser-native cryptography interface available in every modern browser.


The Problem: Spam Is Free

Traditional anti-spam measures fall into two camps:

  1. CAPTCHAs — force the user to prove they are human. Effective, but hostile to UX and increasingly solvable by AI.
  2. Server-side filtering — analyze the submission after it arrives. This works, but the server still has to receive, parse, and evaluate every request.

Both approaches share the same flaw: the attacker pays nothing to send the request. The cost of defense is entirely on the server (and the user).

Proof of Work changes the cost model. Before a form submission is accepted, the client must solve a computational puzzle. For a human filling out a form, the puzzle solves in the background while they type. For a bot blasting thousands of submissions per second, the puzzle becomes a wall. Each submission now costs real CPU cycles.

The math is straightforward. If a PoW challenge takes 200ms to solve, a single bot can only submit 5 forms per second instead of thousands. Scale the difficulty up, and the economics of mass spam fall apart.


How Proof of Work Works

The concept is simple enough to sketch on a napkin.

The Challenge-Response Model

  1. The server generates a challenge: a random string and a difficulty level (e.g., “find a hash that starts with four zeros”).
  2. The client receives the challenge and starts brute-forcing: it appends a counter (called a nonce) to the challenge string, hashes the combination, and checks if the result meets the difficulty requirement.
  3. When the client finds a valid nonce, it submits both the form data and the nonce to the server.
  4. The server verifies the solution in a single hash operation. Verification is O(1). Solving is O(2^d), where d is the number of required leading zero bits.

This is the key property: solving is expensive, verification is cheap. The server spends almost nothing to confirm the work was done.

Why SHA-256?

SHA-256 is the standard choice for PoW puzzles for several reasons:

  • Collision-resistant — no known shortcuts to find a hash with specific properties.
  • Deterministic — the same input always produces the same output, so the server can verify by recomputing.
  • Fast but not too fast — fast enough that a single solve takes milliseconds, slow enough that brute-forcing millions of hashes takes real time.
  • Natively supported — the Web Crypto API provides hardware-accelerated SHA-256 in every modern browser.

Technical Deep Dive: The Web Crypto API

The Web Crypto API (window.crypto.subtle) is a low-level cryptographic interface built into the browser. Unlike libraries like CryptoJS or forge, it runs native code — often backed by OpenSSL or platform-specific hardware acceleration. It is not a polyfill. It is the real thing.

The Basics: Hashing a String

Here is how to compute a SHA-256 hash in the browser:

async function sha256(message) {
  // Encode the string as a Uint8Array
  const msgBuffer = new TextEncoder().encode(message);

  // Hash the message
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);

  // Convert the ArrayBuffer to a hex string
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const hashHex = hashArray
    .map(byte => byte.toString(16).padStart(2, '0'))
    .join('');

  return hashHex;
}

A few things worth noting:

  • crypto.subtle.digest() returns a Promise that resolves to an ArrayBuffer. This is not a string. You have to convert it.
  • The API only works in secure contexts (HTTPS or localhost). If your site is on plain HTTP, this will not work.
  • The operation is asynchronous by design. The browser can offload the computation to a separate thread internally, which avoids blocking the main thread for a single hash. But for PoW, where you need to compute thousands of hashes in a loop, you will want to move the work to a Web Worker (more on that below).

Computing Thousands of Hashes: The PoW Loop

Here is a complete PoW solver. Given a challenge string and a difficulty (number of leading hex zeros required), it finds a nonce whose hash meets the target:

async function solveProofOfWork(challenge, difficulty) {
  const prefix = '0'.repeat(difficulty);
  let nonce = 0;

  while (true) {
    const input = challenge + ':' + nonce;
    const hash = await sha256(input);

    if (hash.startsWith(prefix)) {
      return { nonce, hash };
    }

    nonce++;
  }
}

Difficulty scaling: Each additional hex zero multiplies the average work by 16x. A difficulty of 4 (hash starts with 0000) requires roughly 65,536 attempts on average. A difficulty of 5 requires roughly 1,048,576. You control the cost.

Difficulty (hex zeros) Avg. Attempts Approx. Time (modern browser)
3 ~4,096 ~50ms
4 ~65,536 ~300ms
5 ~1,048,576 ~4s
6 ~16,777,216 ~60s

For form spam prevention, a difficulty of 4 is typically the sweet spot. It is imperceptible to humans (the puzzle solves while they are still typing) but devastating to bots trying to submit at scale.


The Solution: A Full Implementation

Let’s build a working end-to-end system. The server issues a challenge when the page loads. The client solves it in the background. When the user submits the form, the solution is attached to the request.

Step 1: The PoW Worker

Running the hash loop on the main thread would freeze the UI. Use a Web Worker instead:

// pow-worker.js
self.addEventListener('message', async (e) => {
  const { challenge, difficulty } = e.data;
  const prefix = '0'.repeat(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 hashHex = hashArray
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');

    if (hashHex.startsWith(prefix)) {
      self.postMessage({ nonce, hash: hashHex });
      return;
    }

    nonce++;

    // Yield control every 1000 iterations to keep the worker responsive
    if (nonce % 1000 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
});

Step 2: The Client-Side Integration

// main.js
class ProofOfWork {
  constructor(challenge, difficulty) {
    this.challenge = challenge;
    this.difficulty = difficulty;
    this.solution = null;
    this.solved = false;
  }

  start() {
    return new Promise((resolve, reject) => {
      const worker = new Worker('/pow-worker.js');

      worker.addEventListener('message', (e) => {
        this.solution = e.data;
        this.solved = true;
        worker.terminate();
        resolve(e.data);
      });

      worker.addEventListener('error', (e) => {
        worker.terminate();
        reject(e);
      });

      worker.postMessage({
        challenge: this.challenge,
        difficulty: this.difficulty,
      });
    });
  }
}

// Usage: start solving when the page loads
document.addEventListener('DOMContentLoaded', async () => {
  // These values come from the server (e.g., embedded in a data attribute or fetched via API)
  const challenge = document.querySelector('[data-pow-challenge]')?.dataset.powChallenge;
  const difficulty = parseInt(
    document.querySelector('[data-pow-difficulty]')?.dataset.powDifficulty || '4',
    10
  );

  if (!challenge) return;

  const pow = new ProofOfWork(challenge, difficulty);
  const startTime = performance.now();

  pow.start().then(({ nonce, hash }) => {
    const elapsed = (performance.now() - startTime).toFixed(0);
    console.log(`PoW solved in ${elapsed}ms (nonce: ${nonce}, hash: ${hash})`);

    // Inject the solution into the form
    const form = document.querySelector('form');
    if (form) {
      const nonceInput = document.createElement('input');
      nonceInput.type = 'hidden';
      nonceInput.name = 'pow_nonce';
      nonceInput.value = nonce;
      form.appendChild(nonceInput);

      const challengeInput = document.createElement('input');
      challengeInput.type = 'hidden';
      challengeInput.name = 'pow_challenge';
      challengeInput.value = challenge;
      form.appendChild(challengeInput);
    }
  });

  // Block form submission until the puzzle is solved
  const form = document.querySelector('form');
  if (form) {
    form.addEventListener('submit', (e) => {
      if (!pow.solved) {
        e.preventDefault();
        // Optionally show a brief "Verifying..." message
        pow.start().then(() => form.submit());
      }
    });
  }
});

Step 3: Server-Side Verification (PHP Example)

Verification is a single hash operation. Here is a minimal PHP implementation:

function verify_pow(string $challenge, int $nonce, int $difficulty): bool {
    $input = $challenge . ':' . $nonce;
    $hash  = hash('sha256', $input);
    $prefix = str_repeat('0', $difficulty);

    return str_starts_with($hash, $prefix);
}

// In your form handler:
$challenge  = $_POST['pow_challenge'] ?? '';
$nonce      = (int) ($_POST['pow_nonce'] ?? 0);
$difficulty = 4;

// Step 1: Verify the challenge was issued by this server (e.g., HMAC check)
// Step 2: Verify the PoW solution
if (!verify_pow($challenge, $nonce, $difficulty)) {
    http_response_code(403);
    exit('Invalid proof of work.');
}

Critical security note: The code above is simplified for clarity. In production, you need to:

  1. Sign the challenge server-side using an HMAC so the client cannot forge arbitrary challenges.
  2. Include a timestamp in the challenge and reject stale solutions (e.g., older than 5 minutes).
  3. Prevent replay attacks by storing used challenges/nonces and rejecting duplicates.

A production-grade challenge might look like this:

// Generating a signed challenge
$timestamp = time();
$random    = bin2hex(random_bytes(16));
$payload   = $timestamp . ':' . $random;
$signature = hash_hmac('sha256', $payload, $secret_key);
$challenge = $payload . ':' . $signature;

The server can then verify the signature, check the timestamp, and confirm the PoW solution — all without database lookups. Fully stateless.


Performance Considerations

Batching Hashes for Speed

The crypto.subtle.digest() call has per-call overhead due to the async boundary. For maximum throughput, you can batch hashes using a WebAssembly SHA-256 implementation instead. Libraries like hash-wasm can compute SHA-256 roughly 3-5x faster than the Web Crypto API in tight loops because they avoid the Promise overhead.

However, for most anti-spam use cases, the Web Crypto API is more than sufficient. The goal is not to be fast — it is to be slow enough.

Mobile Device Impact

On lower-powered mobile devices, a difficulty-4 challenge might take 500ms-1s instead of 300ms. This is still well within acceptable range for form submissions. If you need tighter control, consider adaptive difficulty: the server issues an easier challenge to clients that identify as mobile (via User-Agent or Client Hints), or you measure the client’s hash rate in the first 100 iterations and adjust dynamically.

Browser Compatibility

The Web Crypto API is supported in all modern browsers:

  • Chrome 37+
  • Firefox 34+
  • Safari 11+
  • Edge 12+

For the roughly 0.5% of users on browsers without support, fall back gracefully. Do not block the form — just skip the PoW and rely on server-side defenses.

if (!window.crypto?.subtle) {
  console.warn('Web Crypto API not available. Skipping PoW.');
  // Allow form submission without PoW; server applies stricter checks
}

PoW vs. Other Anti-Spam Techniques

Technique Bot Cost User Friction Privacy Server Load
reCAPTCHA v2 Medium High Low Low
reCAPTCHA v3 Medium None Low Low
Honeypot fields Low None High Low
Rate limiting Medium Low High Low
Client PoW High None High Very Low

PoW is not a silver bullet. A sophisticated attacker with a GPU farm can solve PoW challenges quickly. But spam is a volume game, and most spam operations run on the thinnest margins. Even modest computational costs, applied across millions of submissions, change the economics dramatically.

The strongest defense is layered security: combine PoW with honeypots, behavioral analysis, and server-side validation. Each layer catches what the others miss.


Putting It Into Practice

If you are running WordPress with Contact Form 7 and want to implement this kind of layered defense without building the infrastructure yourself, Samurai Honeypot for Forms packages several of these techniques — including dynamic honeypots and client-side computational challenges — into a single plugin that requires zero configuration. It runs entirely on your server with no external API calls, which keeps it GDPR-friendly and eliminates third-party dependencies.

But whether you use a plugin or build your own, the principle is the same: make spam expensive. Proof of Work is one of the cleanest ways to do it.


Further Reading

  • MDN: SubtleCrypto.digest() — official documentation for the Web Crypto API hashing function.
  • Hashcash — the original PoW system designed for email anti-spam, created by Adam Back in 1997.
  • Web Workers API — MDN reference for running background threads in the browser.
All Columns