Using hCaptcha in PHP
hCaptcha is a privacy-respecting CAPTCHA service that positions itself as a direct replacement for Google reCAPTCHA. On the free tier, users are shown image-puzzle challenges — similar to the reCAPTCHA v2 experience but without routing data through Google's infrastructure. The API shape is intentionally close to reCAPTCHA's, which makes migration straightforward. Enterprise plans add invisible challenge modes and SLA guarantees.
hCaptcha is a reasonable choice if you want a traditional challenge widget, prefer to avoid Google's data collection, and don't need the fully-invisible experience of Turnstile or reCAPTCHA v3. Sign up at hcaptcha.com, navigate to Sites → Add Site, and copy your site key and secret key before proceeding.
Front-End: Standard Widget
Load the hCaptcha script and place the widget div inside your form. The widget renders automatically and, once the user completes the challenge, writes a token into a hidden field named h-captcha-response that is submitted with the form.
<!-- Load the hCaptcha API -->
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
<form method="post" action="/contact">
<input type="text" name="name" placeholder="Your name" />
<input type="email" name="email" placeholder="Your email" />
<!-- hCaptcha widget — place before the submit button -->
<div class="h-captcha" data-sitekey="YOUR_SITE_KEY"></div>
<button type="submit">Send</button>
</form>
No additional JavaScript is required for the basic flow. The widget handles rendering and token injection automatically once the API script loads.
Server-Side Verification in PHP
Send the token to https://hcaptcha.com/siteverify via POST. The response JSON contains a success boolean and, on failure, an error-codes array. Unlike reCAPTCHA v3, there is no score — hCaptcha free returns a simple pass or fail.
<?php
/**
* Verify an hCaptcha token.
*
* @param string $token The h-captcha-response POST value
* @param string $secretKey Your hCaptcha secret key
* @return bool
*/
function verifyHcaptcha(string $token, string $secretKey): bool
{
if (empty($token)) {
return false;
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/x-www-form-urlencoded',
'content' => http_build_query([
'secret' => $secretKey,
'response' => $token,
'remoteip' => $_SERVER['REMOTE_ADDR'] ?? '',
]),
'timeout' => 5,
],
]);
$response = @file_get_contents('https://hcaptcha.com/siteverify', false, $context);
if ($response === false) {
return false;
}
$data = json_decode($response, true);
return $data['success'] ?? false;
}
// Usage in your form processor:
$token = $_POST['h-captcha-response'] ?? '';
if (!verifyHcaptcha($token, 'YOUR_SECRET_KEY')) {
http_response_code(400);
exit('CAPTCHA verification failed. Please try again.');
}
// Proceed with form handling
If allow_url_fopen is disabled on your server, replace the file_get_contents call with a cURL request using the same POST parameters — the pattern is identical to the cURL examples in the Turnstile guide.
Invisible Mode (Enterprise)
hCaptcha's invisible mode requires an Enterprise subscription. When enabled, it works similarly to reCAPTCHA v3: no puzzle is shown to most users, and the challenge is resolved in the background. To activate it, add data-size="invisible" to the widget div and execute the challenge programmatically before form submission:
<div class="h-captcha"
data-sitekey="YOUR_ENTERPRISE_SITE_KEY"
data-size="invisible"
data-callback="onCaptchaSuccess">
</div>
<script>
function onCaptchaSuccess(token) {
document.getElementById('my-form').submit();
}
document.getElementById('my-form').addEventListener('submit', function (e) {
e.preventDefault();
hcaptcha.execute();
});
</script>
The server-side verification function remains unchanged for invisible mode. For most users on the free tier the standard puzzle widget is the correct choice.
Migrating from reCAPTCHA to hCaptcha
The two services share a similar API design, so migration is a matter of swapping out four things: the script URL, the CSS class on the widget div, the POST field name, and the verification endpoint.
Front-end changes:
<!-- Before (reCAPTCHA v2) -->
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<div class="g-recaptcha" data-sitekey="OLD_GOOGLE_SITE_KEY"></div>
<!-- After (hCaptcha) -->
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
<div class="h-captcha" data-sitekey="NEW_HCAPTCHA_SITE_KEY"></div>
PHP verification changes:
<?php
// Before: reCAPTCHA v2
$token = $_POST['g-recaptcha-response'] ?? '';
$verifyUrl = 'https://www.google.com/recaptcha/api/siteverify';
// Response required 'success' === true (no score on v2)
// After: hCaptcha
$token = $_POST['h-captcha-response'] ?? ''; // field name changed
$verifyUrl = 'https://hcaptcha.com/siteverify'; // endpoint changed
// Response still requires 'success' === true — logic is identical
The POST body parameters (secret, response, remoteip) are the same for both services. If you have a shared verification helper, updating the field name read from $_POST and the endpoint URL is all that's needed.
Troubleshooting
- Widget not appearing: Confirm the hCaptcha script loaded without errors (check the browser console). Verify the site key is correct and the domain is listed in your hCaptcha site settings — the domain must match exactly, including subdomains.
- "missing-input-response": The form was submitted before the user completed the CAPTCHA. Ensure the submit button is not triggered programmatically before the widget has issued a token, and consider disabling the submit button until the widget fires its success callback.
- "invalid-input-secret": The secret key sent from PHP does not match what's in your hCaptcha dashboard. Check for extra whitespace or accidental truncation when reading the key from environment variables or config files.
- Token already used: hCaptcha tokens are single-use. If your form processor is called more than once for the same submission (e.g., from a retry on validation failure), the token will be rejected on the second call. Generate a fresh challenge for each attempt.