loke.dev
Header image for How to Shield Your Most Expensive API Routes Without the UX Friction of a CAPTCHA

How to Shield Your Most Expensive API Routes Without the UX Friction of a CAPTCHA

Force malicious scrapers to pay a computational tax using the Web Crypto API before your server ever touches an expensive inference or database request.

· 4 min read

Every time a bot hits your LLM inference endpoint or triggers a massive database aggregation, you’re essentially handing your cloud budget to someone who doesn't even like your product. Traditional rate-limiting helps, but smart scrapers rotate IPs faster than you can ban them, and forcing real users to identify traffic lights in a CAPTCHA is a great way to kill your conversion rate.

There is a middle ground: making the client pay a "computational tax." By using a Proof of Work (PoW) mechanism powered by the Web Crypto API, you can force a browser to spend a few hundred milliseconds of CPU time solving a puzzle before your server even looks at the request. For a human, it’s a tiny delay; for a botnet trying to hit you 10,000 times a second, it’s a death sentence for their hardware overhead.

The "Proof of Work" Concept

The idea is simple. Your server issues a challenge (a random string and a difficulty level). The client must find a "nonce" (a random number) that, when combined with the challenge and hashed, produces a result meeting a specific condition—like starting with four zeros.

Hashing is fast, but finding the right hash is a brute-force game. It’s asymmetrical: the client works hard to find the answer, but the server verifies it in a fraction of a millisecond.

Implementing the Client-Side Solver

The Web Crypto API is perfect here because it’s built into almost every modern browser and runs much faster than a pure JavaScript implementation. We don't want to freeze the UI, so we’ll use SubtleCrypto.

Here is a lean implementation of a solver. It takes a challenge and a difficulty (the number of leading zeros required).

async function solveChallenge(challenge, difficulty) {
  const encoder = new TextEncoder();
  let nonce = 0;
  const target = '0'.repeat(difficulty);

  while (true) {
    const data = encoder.encode(challenge + nonce);
    const hashBuffer = await crypto.subtle.digest('SHA-256', data);
    
    // Convert buffer to hex string
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');

    if (hashHex.startsWith(target)) {
      return { nonce, hashHex };
    }
    nonce++;
    
    // Safety break to prevent infinite loops if something goes wrong
    if (nonce > 1000000) throw new Error("Difficulty too high or timeout");
  }
}

In a real-world app, you’d probably run this inside a Web Worker so the main thread stays buttery smooth while the CPU is crunching numbers.

The Server: Issuing the Challenge

Your server needs to be stateless (or at least efficient) when issuing challenges. You can’t just trust any random string, or a bot could pre-calculate answers. I like to use a signed JWT or a timestamped string that includes the user's IP or Session ID.

Here’s a basic Node.js example using crypto to verify the solution:

const crypto = require('crypto');

function verifyWork(challenge, nonce, difficulty) {
  const target = '0'.repeat(difficulty);
  const hash = crypto.createHash('sha256')
    .update(challenge + nonce)
    .digest('hex');

  return hash.startsWith(target);
}

// Example usage in an Express route
app.post('/api/expensive-inference', (req, res) => {
  const { challenge, nonce, payload } = req.body;
  const difficulty = 4; // Adjust based on current load

  // 1. Verify the challenge hasn't expired (e.g., it's a timestamp < 5 mins old)
  // 2. Check if the PoW is valid
  if (!verifyWork(challenge, nonce, difficulty)) {
    return res.status(401).json({ error: "Invalid Proof of Work" });
  }

  // Now perform the expensive operation...
  const result = runExpensiveAIModel(payload);
  res.json(result);
});

Why this beats a CAPTCHA

The beauty of this approach is that it's invisible.

If I’m a legitimate user clicking "Generate Image," a 300ms delay while my browser calculates a hash is unnoticeable—especially since the backend inference will take a few seconds anyway. But if I’m a scraper script written in Python, I now have to implement a JS-compatible hashing loop. If I want to hit that API 100 times, I have to actually burn 30 seconds of CPU time.

It flips the economics of scraping. Instead of your server burning money on GPU cycles, the attacker burns money on their own compute power.

The Catch: Calibration and Battery Life

You have to be careful not to overdo it. If you set the difficulty to 6 or 7, mobile devices will start to heat up, and the "tax" becomes a UX penalty.

I’ve found that a "sliding difficulty" works best:
- Low Load: Difficulty 3 (solves in ~10-50ms).
- High Load/Suspicious IP: Difficulty 5 (solves in ~500ms-2s).
- Under Attack: Difficulty 6+ or fallback to a hard CAPTCHA.

Also, keep in mind that SHA-256 is fast. If an attacker is using a GPU-accelerated cracker, they’ll chew through these puzzles much faster than a browser. However, the goal usually isn't to be "unhackable"—it's to be more expensive to attack than the data is worth.

Handling Replay Attacks

A critical security hole in the basic implementation is the "Replay Attack." If a bot solves the puzzle once, what's stopping them from sending that same challenge + nonce 1,000 times?

To fix this, you need to:
1. Tie the challenge to the request: Include a unique ID or a timestamp in the challenge.
2. Track nonces: Keep a short-lived cache (like Redis) of used challenges for the last 5-10 minutes. If the server sees the same challenge/nonce pair twice, reject it.

Wrapping Up

Moving security logic to the client side feels counter-intuitive until you realize you're just outsourcing the "cost of entry." By using the Web Crypto API, you protect your most expensive routes from automated abuse without making your real users hunt for crosswalks in grainy photos.

It’s elegant, it’s performant, and it keeps your AWS bill from spiraling out of control when the scrapers come knocking.