Java – reCAPTCHA v2

reCAPTCHA son dos mitades: un widget en el formulario, que es HTML, y una comprobación en el servidor, que es la parte de Java y la única que de verdad protege. Sin la segunda, el widget es decoración.

Las dos claves

En la consola de administración de reCAPTCHA se registra el dominio y se obtienen dos claves:

ClaveDónde vaSe puede ver
De sitio (site key)en el HTML, a la vistasí, es pública
Secreta (secret key)solo en el servidornunca
La clave secreta no se escribe en el código ni se sube al repositorio. Va en una variable de entorno o en el gestor de secretos que uses. Si se filtra, cualquiera puede validar captchas en tu nombre y hay que regenerarla.

1. El widget, en el formulario

<html>
<head>
  <script src="https://www.google.com/recaptcha/api.js" async defer></script>
</head>
<body>
  <form action="/registro" method="POST">
    <input type="text" name="nombre" required>
    <input type="email" name="correo" required>

    <div class="g-recaptcha" data-sitekey="TU_CLAVE_DE_SITIO"></div>

    <button type="submit">Enviar</button>
  </form>
</body>
</html>

El <div class="g-recaptcha"> es donde aparece la casilla «no soy un robot». Al enviar el formulario, el navegador añade un campo oculto llamado g-recaptcha-response con un testigo. Ese testigo es lo que hay que comprobar.

2. La verificación, en el servidor

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;

public class Recaptcha {
    private static final HttpClient CLIENTE = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10)).build();

    static boolean verificar(String secreto, String respuesta, String ip) throws Exception {
        String cuerpo = "secret=" + enc(secreto) + "&response=" + enc(respuesta)
                      + (ip == null ? "" : "&remoteip=" + enc(ip));

        HttpRequest pet = HttpRequest.newBuilder()
                .uri(URI.create("https://www.google.com/recaptcha/api/siteverify"))
                .header("Content-Type", "application/x-www-form-urlencoded")
                .timeout(Duration.ofSeconds(15))
                .POST(HttpRequest.BodyPublishers.ofString(cuerpo))
                .build();

        HttpResponse<String> r = CLIENTE.send(pet, HttpResponse.BodyHandlers.ofString());
        return r.body().contains("\"success\": true");
    }

    private static String enc(String s) {
        return URLEncoder.encode(s, StandardCharsets.UTF_8);
    }
}

Esto es con el HttpClient del JDK, sin dependencias. Y así responde Google cuando el testigo no vale —la salida es de una llamada de verdad al servicio, con una clave inventada—:

salida{
  "success": false,
  "error-codes": [
    "invalid-input-response"
  ]
}

Para analizar la respuesta en serio conviene Gson en vez de buscar un texto dentro de la cadena:

record RespuestaCaptcha(boolean success,
                        String challenge_ts,
                        String hostname,
                        java.util.List<String> errorCodes) {}

var datos = new Gson().fromJson(r.body(), RespuestaCaptcha.class);
if (!datos.success()) {
    log.warn("captcha rechazado: {}", datos.errorCodes());
}

En un servlet

@Override
protected void doPost(HttpServletRequest pet, HttpServletResponse res)
        throws ServletException, IOException {

    String testigo = pet.getParameter("g-recaptcha-response");

    if (testigo == null || testigo.isBlank()
            || !Recaptcha.verificar(System.getenv("RECAPTCHA_SECRET"), testigo, pet.getRemoteAddr())) {
        res.sendError(HttpServletResponse.SC_BAD_REQUEST, "Verifica que no eres un robot");
        return;
    }

    // a partir de aquí, el formulario es de fiar
}

Los códigos de error

CódigoQué ha pasado
missing-input-secretno mandaste la clave secreta
invalid-input-secretla clave secreta es incorrecta o no es la del dominio
missing-input-responseno llegó el testigo: el usuario no marcó la casilla
invalid-input-responseel testigo es falso, está caducado o ya se usó
timeout-or-duplicateel testigo ya se había validado, o pasaron más de 2 minutos
Un testigo sirve una sola vez y caduca a los dos minutos. Si en las pruebas te sale timeout-or-duplicate, casi siempre es porque has reenviado el mismo formulario o porque estás verificando dos veces la misma respuesta.

Dos cosas que conviene tener claras

La comprobación va siempre en el servidor. El widget se puede saltar; el testigo no, porque solo Google sabe si es válido y solo tu clave secreta puede preguntárselo. Validar en JavaScript no protege de nada: quien envía el formulario con un programa se salta tu JavaScript entero.

Y conviene no depender solo del captcha. Si Google no responde, tu formulario deja de funcionar: hay que decidir de antemano qué hacer con el tiempo de espera agotado. Un campo trampa invisible y un límite de envíos por IP hacen bastante trabajo, no envían datos de tus usuarios a nadie y no se caen.

v2 sigue funcionando, pero ya no es lo que Google recomienda. La versión 3 no muestra ninguna casilla: devuelve una puntuación de 0 a 1 y decides tú el umbral. Y desde hace unos años el camino oficial para proyectos nuevos es reCAPTCHA Enterprise, que va por Google Cloud. Si lo que mantienes es un formulario que ya usa v2, no hay prisa; si empiezas de cero, mira primero la v3.

Todo el código se compiló y se ejecutó con Java 25 LTS en un contenedor limpio antes de publicar esta página; las salidas están copiadas de esa ejecución.