Nadie elige un proveedor de captcha pensando en el día que quiera cambiarlo, y sin embargo ese día llega: sube de precio, empeora la tasa de falsos positivos o el cliente exige que sus datos no pasen por según qué servidor. Lo que sigue es cómo integrarlo desde el principio para que ese cambio sea una línea de configuración y no un rastreo por todo el proyecto, y dónde colocar la verificación para que sirva de algo. Los ejemplos van en Laravel porque es una de nuestras especialidades en AndorraDev, y comparamos con lo que ya existe en su ecosistema, pero la abstracción que proponemos es la misma en cualquier framework.
Poner un captcha es fácil, cambiarlo es lo que duele
Integrar Turnstile son quince minutos. Copias el div, cargas el script, validas el token contra el endpoint del proveedor y listo.
El problema aparece un año después, cuando quieres cambiar. Y no es un problema de esfuerzo, es de superficie de contacto: el proveedor no aparece en un sitio, aparece en cuatro.
El nombre del campo cambia. Turnstile manda cf-turnstile-response, reCAPTCHA manda g-recaptcha-response. Esa cadena está escrita en tu formulario, en tu regla de validación y en tu JavaScript.
La URL del script cambia, y va en el layout o en la vista.
El marcado cambia. Una clase cf-turnstile no es una clase g-recaptcha.
Y el flujo cambia entre versiones: la versión invisible de reCAPTCHA no pinta ningún recuadro, se ejecuta al enviar el formulario e inyecta el token, con lo que necesita un JavaScript que la anterior no necesitaba.
Multiplica eso por los formularios que tengas y ya no es un cambio de proveedor, es una tarde.
Cinco métodos bastan para abstraer un captcha
Lo que hay que aislar es exactamente lo que varía, y resulta ser muy poco:
interface CaptchaDriver
{
public function name(): string;
public function verify(string $token, ?string $ip = null): VerificationResult;
public function siteKey(): string;
public function scriptUrl(): string;
public function responseField(): string;
}
Con eso, cambiar de proveedor es cambiar una variable de entorno, siempre que en tu aplicación no haya quedado ninguna cadena literal.
Y ahí está la clave práctica: la abstracción solo funciona si la usas entera. Si pintas el widget a mano y escribes el nombre del campo en el controlador, tienes la interfaz puesta y el acoplamiento intacto. Merece la pena revisarlo antes de dar la integración por cerrada: basta con buscar en el proyecto las cadenas del proveedor y comprobar que no queda ninguna.
El token se puede reutilizar si no lo impides
La documentación de los proveedores termina en "el token es válido", y ahí es donde para casi todo el mundo. El problema es que válido no significa nuevo, y un captcha que acepta el mismo token dos veces no está frenando a nadie que se moleste en mirar.
Un token de captcha es válido durante unos minutos. Si tu servidor solo comprueba que sea válido, un script puede resolver el captcha una vez y reenviar el mismo token muchas veces dentro de esa ventana. Has verificado que hubo un humano, no que hay un humano en cada envío.
La protección es sencilla y hay que ponerla a propósito: guardar una huella del token al validarlo y rechazarlo si vuelve a aparecer.
$cacheKey = 'captcha|' . md5($token);
if (Cache::has($cacheKey)) {
$fail('captcha.used');
return;
}
// ... verificación contra el proveedor ...
Cache::put($cacheKey, true, now()->addMinutes(5));
El plazo debe parecerse a la validez del propio token. Cinco minutos encaja bien con Turnstile, cuyos tokens duran unos trescientos segundos.
La protección antirreutilización es lo que impide que un token resuelto una vez sirva para muchos envíos. Si tu formulario se reenvía sin recargar la página, limpia el campo oculto del token después de cada envío para que el JavaScript genere uno nuevo. Son dos líneas y dejan la protección haciendo su trabajo sin que el usuario lo note.
Verifica la acción, no solo la puntuación
Esto es específico de la versión que puntúa el comportamiento en vez de mostrar un recuadro, y es donde más gente se queda corta.
Esa versión devuelve tres cosas: si el token es válido, una puntuación de cero a uno, y la acción que declaraste al generarlo. Casi todo el mundo comprueba la puntuación y se olvida de la acción.
El problema es que un token generado en cualquier página de tu sitio es válido en cualquier otra. Si tienes un formulario de suscripción a la newsletter con umbral bajo y un formulario de registro con umbral alto, un atacante puede minar tokens en el barato y gastarlos en el caro. Lo mismo con el nombre de dominio: si no compruebas que el token se generó en tu sitio, sirve un token generado en otro.
Comprobarlo es un argumento más en dos sitios. El widget marca el token con la acción de este formulario, y la regla exige esa misma acción:
<x-laracaptcha::widget action="register" />
'g-recaptcha-response' => ['required', Captcha::make('recaptcha_v3', 'register')],
Un token generado para cualquier otra cosa se rechaza con action-mismatch, y también uno que llegue sin acción. Los proveedores cuyos tokens no la llevan, Turnstile entre ellos, la ignoran sin que tengas que cambiar nada al alternar de uno a otro.
Un captcha protege el paso caro, no el formulario
Se tiende a pensar en el captcha como en un portero de puerta: lo pones en la entrada y ya está. Pero no todos los formularios cuestan lo mismo de atacar ni de defender, y poner el filtro en el sitio equivocado molesta a los usuarios legítimos sin frenar a los que importan.
La reacción por defecto es poner el captcha en el formulario de acceso. Y muchas veces está mal, porque el acceso ya está protegido por otra cosa: para entrar hay que acertar una contraseña, y para eso el límite de intentos es una defensa mejor y no molesta a nadie.
Lo que de verdad quieres proteger es la operación que te cuesta dinero o reputación. En un proyecto nuestro el captcha no protege el acceso: protege el paso previo al alta, justo antes de enviar el código de verificación por correo. El motivo está escrito en el propio controlador y es exacto: para no mandar correos a bots. Si el correo ya existe, es un acceso normal y el captcha ni aparece.
Esa es la pregunta correcta: no "qué formulario protejo", sino "qué acción se dispara al enviarlo". Si la acción es escribir una fila, el límite de peticiones suele bastar. Si es mandar un correo, llamar a un modelo de pago o crear un recurso en un tercero, ahí sí.
Decide qué pasa cuando el proveedor no responde
Añadir un captcha significa meter un tercero en el camino crítico de tus formularios. Casi nadie decide qué debe pasar si ese tercero se cae, y el resultado es que la decisión la acaba tomando el código por accidente, normalmente de la peor manera posible.
Verificar un captcha es una llamada HTTP a un servicio externo. Si ese servicio tarda, tu petición se queda esperando, y si no responde, algo tiene que pasar. Las dos opciones:
Fallar cerrado: sin verificación no se pasa. Es lo seguro y significa que una caída del proveedor tumba tus formularios.
Fallar abierto: si no se puede verificar, se deja pasar y se registra. Mantienes el servicio y aceptas una ventana en la que entra todo.
Lo que no puede pasar es que la decisión la tome un error no controlado y el usuario reciba un error del servidor. Una verificación que no se puede completar tiene que llegar a tu regla de validación como verificación fallida, no como excepción.
Y va en el mismo lote el tiempo máximo de espera. Cinco segundos sobra para un siteverify; los treinta que heredas del cliente HTTP si no lo fijas te retienen un proceso PHP demasiado tiempo, y eso con tráfico se nota antes que ninguna otra cosa.
Hay además un caso intermedio que casi siempre se olvida: una respuesta que llega pero no es JSON, la página de error de un intermediario por ejemplo. No es un fallo de conexión, así que hay que tratarla aparte, y lo correcto es que tampoco pase: decodifica a vacío, no trae confirmación de éxito y por tanto falla igual.
Las tres decisiones vienen tomadas de serie en nuestro caso: falla cerrado, con cinco segundos configurables, y una respuesta que no sea JSON tampoco pasa. Si tu formulario necesita lo contrario, se cambia; lo que no vas a tener nunca es un quinientos porque Cloudflare tarde en contestar.
Qué hay en el mercado y en qué se diferencia
El ecosistema está bien surtido y las opciones se ordenan por acoplamiento.
Los paquetes de un solo proveedor son la mayoría y funcionan muy bien dentro de su ámbito. ryangjchandler/laravel-cloudflare-turnstile cubre Turnstile con una ergonomía muy cuidada, especialmente para tests, donde su simulador permite forzar fallo o caducidad. anhskohbo/no-captcha es el veterano de reCAPTCHA y acumula millones de instalaciones. El compromiso de los dos es el mismo y es el punto de este artículo: cambiar de proveedor es reescribir formularios, reglas y vistas, no cambiar una variable.
La librería oficial de Google expone la verificación al detalle: acción, nombre de dominio y umbral se exigen en la propia llamada. Lo que no trae es nada de Laravel: ni regla de validación, ni componente, ni facade. Escribes tú la integración y quedas atado a reCAPTCHA.
El campo trampa es otra capa distinta y muy infravalorada: un campo oculto que un humano nunca rellena, más una marca temporal. Cero JavaScript de terceros, cero problema con la normativa de datos y coste operativo nulo. Frena bots genéricos, no frena bots dirigidos. No compite con un captcha, se suma.
Y el límite de peticiones ya lo tienes en el framework. No distingue un humano de un bot, solo limita la frecuencia, y para muchos formularios es todo lo que hacía falta.
Y si no trabajas con Laravel
Los criterios son los mismos en cualquier lenguaje:
- Aísla lo que varía, que es el nombre del campo, la clave pública, la URL del script y el marcado. Son cuatro cosas.
- Y luego úsalo de verdad. Una interfaz con cadenas literales repartidas por las vistas no te va a salvar el día del cambio.
- Impide la reutilización del token. Una huella en caché y listo.
- Comprueba la acción y el dominio, no solo la puntuación.
- Pon el captcha delante de la operación cara, no delante de todos los formularios.
- Y decide a propósito qué pasa cuando el proveedor no responde, con un tiempo máximo de espera fijado por ti.
Dónde está el código
El paquete está en Packagist, el código en GitHub y la documentación del paquete recoge la configuración de los tres proveedores. Es MIT, sin tablas, sin rutas y sin dependencias de terceros: usa el cliente HTTP que ya trae Laravel.
Si tienes formularios públicos que disparan correos o llamadas de pago, esto es de lo más barato que puedes poner y de lo que más ruido quita. Lo trabajamos en aplicaciones web y diseño web, y si nos cuentas qué te está entrando te decimos qué capa te falta.