Google reCAPTCHA en Laravel: proteger un formulario sin paquete

Google reCAPTCHA en Laravel: proteger un formulario sin paquete
Respuesta rápida

No hace falta ningún paquete: basta con una clase que implemente ValidationRule y consulte https://www.google.com/recaptcha/api/siteverify con la fachada Http. Comprueba tres cosas en la respuesta: success, la action esperada y el score. Y acompaña siempre la regla con required: Laravel no ejecuta una regla personalizada sobre un campo vacío.

Un reCAPTCHA se reduce a dos cosas: un token que genera el navegador y una comprobación de ese token contra Google desde tu servidor. Laravel trae todo lo necesario para la segunda parte, lo que convierte el paquete de terceros en algo opcional.

Artículo del itinerario Desarrollo web. Esta guía monta una regla de validación autónoma, sin dependencias, y la somete a seis escenarios. Verificado en Laravel 13.30.1 con PHP 8.4.25.

Conseguir las claves

Crea un sitio en la consola de reCAPTCHA y elige la versión v3, que asigna una puntuación de 0 a 1 sin pedirle nada al visitante. Obtienes dos claves: la clave de sitio, pública, y la clave secreta, que no debe salir nunca del servidor.

.env
RECAPTCHA_KEY=votre_cle_de_site
RECAPTCHA_SECRET=votre_cle_secrete

Decláralas en la configuración en lugar de llamar a env() desde el código: una vez que la configuración está en caché, env() devuelve null en cualquier sitio que no sea config/.

config/services.php
'recaptcha' => [
    'key'    => env('RECAPTCHA_KEY'),
    'secret' => env('RECAPTCHA_SECRET'),
],

La regla de validación

Basta con una clase que implemente ValidationRule. Consulta la API de verificación y comprueba el éxito, la acción esperada y la puntuación.

bash
php artisan make:rule Recaptcha
app/Rules/Recaptcha.php
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Http;

class Recaptcha implements ValidationRule
{
    public function __construct(
        private string $action,
        private float $minScore = 0.5,
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (! is_string($value) || $value === '') {
            $fail('La vérification anti-robot est absente.');
            return;
        }

        $response = Http::asForm()
            ->timeout(5)
            ->post('https://www.google.com/recaptcha/api/siteverify', [
                'secret'   => config('services.recaptcha.secret'),
                'response' => $value,
                'remoteip' => request()->ip(),
            ]);

        if ($response->failed()) {
            $fail('La vérification anti-robot est indisponible. Réessayez.');
            return;
        }

        $data = $response->json();

        if (! ($data['success'] ?? false)) {
            $fail('La vérification anti-robot a échoué.');
            return;
        }

        if (($data['action'] ?? null) !== $this->action) {
            $fail('La vérification anti-robot ne correspond pas à ce formulaire.');
            return;
        }

        if (($data['score'] ?? 0) < $this->minScore) {
            $fail('Votre requête a été considérée comme automatisée.');
        }
    }
}

La comprobación de la acción merece una explicación: sin ella, un token obtenido en cualquier página pública de tu sitio podría servir para superar la validación del formulario de contacto. La puntuación por sí sola no basta.

Enganchar la regla al formulario

Añade required o la protección no sirve de nada

Laravel no ejecuta una regla personalizada sobre un valor vacío o ausente. Comprobado: con la regla Recaptcha a secas, un campo vacío pasa la validación sin que salga ni una petición hacia Google. Bastaría entonces con no enviar el campo para saltarse el captcha. Aquí la regla required no es opcional.

app/Http/Controllers/ContactController.php
use App\Rules\Recaptcha;

public function store(Request $request)
{
    $request->validate([
        'email'                => ['required', 'email'],
        'message'              => ['required', 'string', 'max:2000'],
        'g-recaptcha-response' => ['required', new Recaptcha('contact', 0.5)],
    ]);

    // …
}

El formulario en el navegador

El formulario de abajo se apoya en las reglas de escapado de Blade. La v3 no muestra ninguna casilla: el script genera un token que se coloca en un campo oculto justo antes del envío.

resources/views/contact.blade.php
<form method="POST" action="/contact" id="contact">
    @csrf
    <input type="email" name="email" required>
    <textarea name="message" required></textarea>
    <input type="hidden" name="g-recaptcha-response" id="recaptcha-token">
    <button type="submit">Envoyer</button>
</form>

@error('g-recaptcha-response')
    <p class="erreur">{{ $message }}</p>
@enderror

<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.key') }}"></script>
<script>
document.getElementById('contact').addEventListener('submit', function (e) {
    e.preventDefault();
    grecaptcha.ready(() => {
        grecaptcha.execute(@json(config('services.recaptcha.key')), { action: 'contact' })
            .then(token => {
                document.getElementById('recaptcha-token').value = token;
                e.target.submit();
            });
    });
});
</script>

La acción declarada aquí, contact, tiene que ser exactamente la que espera la regla en el servidor.

Probarlo sin llamar a Google

Una protección que no se prueba es una protección cuyo estado desconoces. Acuérdate también de desactivar el modo de depuración en producción, o expondrás tu clave secreta en una página de error.

Http::fake() permite simular cada respuesta de la API:

tests/Feature/RecaptchaTest.php
use App\Rules\Recaptcha;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Facades\{Http, Validator};

$cas = [
    'succès, score 0.9'       => [200, ['success' => true,  'action' => 'contact', 'score' => 0.9]],
    'succès, score 0.1'       => [200, ['success' => true,  'action' => 'contact', 'score' => 0.1]],
    'action différente'       => [200, ['success' => true,  'action' => 'login',   'score' => 0.9]],
    'jeton refusé par Google' => [200, ['success' => false, 'error-codes' => ['invalid-input-response']]],
    'service indisponible'    => [500, ''],
];

foreach ($cas as $label => [$code, $corps]) {
    Http::swap(new Factory());   // arranca con un cliente nuevo en cada caso
    Http::fake(['*' => Http::response($corps, $code)]);

    $v = Validator::make(
        ['g-recaptcha-response' => 'un-jeton'],
        ['g-recaptcha-response' => ['required', new Recaptcha('contact', 0.5)]],
    );

    printf("%-26s %s\n", $label, $v->passes() ? 'ACCEPTÉ' : 'refusé : '.$v->errors()->first());
}

Resultados obtenidos:

code
succès, score 0.9          ACCEPTÉ
succès, score 0.1          refusé : Votre requête a été considérée comme automatisée.
action différente          refusé : La vérification anti-robot ne correspond pas à ce formulaire.
jeton refusé par Google    refusé : La vérification anti-robot a échoué.
service indisponible       refusé : La vérification anti-robot est indisponible. Réessayez.
Http::fake() acumula las simulaciones

Llamar a Http::fake() varias veces en el mismo test no borra las anteriores: responde la primera coincidencia, y todos los casos siguientes reciben la respuesta del primero. De ahí el Http::swap(new Factory()) antes de cada caso. Sin él, la batería entera pasa en verde sin comprobar nada.

¿Hace falta un paquete?

anhskohbo/no-captcha, citado en la versión anterior de este artículo, sigue existiendo en la versión 3.8. Ahorra unas cuantas líneas, sobre todo las de mostrar el widget de la v2.

El código de arriba cabe en una clase y una llamada HTTP, sin dependencias que mantener, y te deja el control de la puntuación mínima, de la comprobación de la acción y de los mensajes de error. Para un formulario, es la mejor relación.

Un apunte si empiezas de cero: Cloudflare Turnstile cumple el mismo papel con una API de verificación de la misma forma. Solo cambian la URL de verificación y el nombre del campo, la estructura de la regla es idéntica.

Errores frecuentes

Olvidar required junto a la regla Laravel no ejecuta una regla personalizada sobre un valor vacío o ausente. Sin required, basta con no enviar el campo para saltarse el captcha por completo. Comprobado.
No comprobar la acción Un token obtenido en cualquier página del sitio pasaría la validación del formulario. La puntuación por sí sola no protege.
Llamar a env() desde la regla En cuanto la configuración está en caché, env() devuelve null. Usa config('services.recaptcha.secret').
Http::fake() acumula las simulaciones Una segunda llamada no borra la primera: responde la primera coincidencia. Sin Http::swap(new Factory()), toda la batería de pruebas pasa en verde sin comprobar nada.
Ningún tiempo de espera en la llamada Sin timeout(), una caída de la API bloquea la petición del usuario hasta el límite por defecto de PHP.
Exponer la clave secreta Solo la clave de sitio va en el HTML. La clave secreta se queda en el servidor.

FormulairesLaravelPHPSécurité

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.