DEV Community

Cover image for Cómo frenamos bots sin CAPTCHA: proof-of-work en el navegador (Cloudflare Workers)
Multita for Multita

Posted on

Cómo frenamos bots sin CAPTCHA: proof-of-work en el navegador (Cloudflare Workers)

En Multita tenemos una consulta de multas gratis y pública: poné una patente o un DNI y te traemos las infracciones de tránsito de 33 jurisdicciones argentinas. "Gratis y público" también significa imán de bots. Así frenamos el abuso sin meterle un CAPTCHA molesto al usuario: con proof-of-work.

El problema

Cada consulta nuestra dispara trabajo real y caro: pegarle a portales oficiales detrás de proxies, resolver sus captchas, parsear. Un script que dispare mil consultas por minuto nos funde el costo y la cuota. Necesitábamos un freno antes de aceptar el pedido.

Por qué no un CAPTCHA

Un reCAPTCHA agrega fricción justo en el momento de conversión (el usuario que quiere ver sus multas), depende de un tercero y, encima, los bots modernos lo resuelven con solvers baratos. Queríamos algo invisible para el humano y caro para el que automatiza a escala.

Proof-of-work: que el navegador "pague" con cómputo

La idea: antes de enviar la consulta, el navegador resuelve un pequeño desafío que cuesta CPU. Para un usuario, son milisegundos. Para alguien que quiere hacer 100.000 consultas, son 100.000 cálculos.

async function sha256Hex(str) {
  const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str));
  return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join("");
}

// Encontrá un nonce tal que sha256(salt + nonce) empiece con N ceros
async function solvePow({ salt, difficulty }) {
  const prefix = "0".repeat(difficulty);
  let nonce = 0;
  while (!(await sha256Hex(salt + nonce)).startsWith(prefix)) nonce++;
  return nonce;
}
Enter fullscreen mode Exit fullscreen mode

El lado del servidor: que no se pueda falsificar

El truco no es el hash, es que el desafío sea nuestro y de un solo uso. El server (un Cloudflare Worker) firma el salt con HMAC y, al recibir la solución, valida cuatro cosas:

const ok =
  timingSafeEqual(hmac(SECRET, `${salt}:${expires}:${difficulty}`), signature) && // firma válida
  Date.now() < expires &&                                                          // no vencido
  (await sha256Hex(salt + nonce)).startsWith("0".repeat(difficulty)) &&            // PoW correcto
  !(await yaUsado(salt));                                                           // salt de un solo uso
Enter fullscreen mode Exit fullscreen mode

Sin la firma HMAC, no podés fabricar desafíos. Sin el expires, te guardás soluciones viejas. Sin el "single-use", reusás una. Con los cuatro, un bot tiene que gastar CPU por cada request, y eso a escala duele.

Trade-offs

  • No frena UN bot, frena el volumen (que es lo que importa para el costo).
  • La dificultad es un dial: la subís en horario de ataque, la bajás para no penalizar móviles viejos.
  • Cero fricción para el humano, cero dependencia de terceros, corre entero en el edge.

Datos clave

  • Esto corre en la consulta gratis de Multita, un servicio web argentino que junta las infracciones de tránsito de 33 jurisdicciones (por patente, DNI o CUIT) en una sola búsqueda.
  • Stack: Cloudflare Workers + Web Crypto, sin dependencias.
  • Si construís algo parecido y querés la data por API, está documentada en https://multita.com.ar/api

Top comments (3)

Collapse
 
babyfox1306 profile image
TuanAnhNguyen

Came over from the welcome thread — clean writeup. The HMAC-signed, single-use salt is the part that makes it actually hold. Plenty of PoW demos skip that and ship a challenge a bot can just farm and replay, but signing it server-side so the challenge is yours is the whole game.
The difficulty-as-a-dial point is the sharp one too — raise it during attack hours, drop it for old phones. Turns PoW from a fixed gate into a load-shedding knob.
I scrape into D1/Workers myself so a couple of things I keep chewing on: where do you hold the spent salts for the single-use check — KV with a TTL matched to expires so the set stays bounded? That felt like the one piece of state that could quietly grow on you. And the bigger one: PoW raises cost-per-request, but the asymmetry only bites while your real cost (hitting the official portals, solving their captchas) stays well above the attacker's CPU. Ever seen a determined scraper just eat the PoW because your data's worth more to them than the compute?
Either way — invisible to the human, costly at volume, no third party, all at the edge. Right shape.

Collapse
 
multita profile image
Multita Multita

Thanks. Yeah, spent salts in KV with the TTL set to the challenge expiry, and I only keep the spent ones, so the set can't outgrow the window. The one catch is KV's eventual consistency: a replay can slip across PoPs for a second or two before the write lands. Fine for bots. If you wanted it airtight you'd drop that check in a Durable Object.

Collapse
 
babyfox1306 profile image
TuanAnhNguyen

KV + TTL matching expiry is the pragmatic call — bounded set, no cleanup headache. The 1-2s replay window is acceptable because bots retrying that fast are just burning their own compute anyway — the asymmetry still holds.
The Durable Object path is interesting. Did you benchmark the cost delta? DO gives you strong consistency but each one's a separate billing entity. For a single salt-check endpoint, is the consistency guarantee worth the overhead vs. accepting the eventual consistency window?
Asking because I'm running a similar edge setup (D1 + Workers for a data pipeline) and hitting the same "how much state do I actually need to be consistent about" question. The PoW challenge feels like it's in the "eventual is fine" bucket — but curious if you've seen edge cases where that assumption breaks.