Previous Article
Auto-Refresh a Div Every 10 Seconds in JavaScript (Without setInterval)
2 September 2018
reCAPTCHA protects forms from automated abuse. It is also routinely implemented in a way that provides no protection at all — because the part that matters happens on your server, and most tutorials stop before that.
This guide covers what each version does, how to verify a token in PHP, and how to decide what to do with a v3 score.
Adding the widget to your HTML does nothing on its own:
<!-- This alone protects NOTHING --> <div class="g-recaptcha" data-sitekey="your-site-key"></div>
The widget produces a token. Unless your server sends that token to Google and checks the answer, a bot simply posts directly to your endpoint and skips the widget entirely — it never loads your page, so there is nothing to solve.
The security boundary is the server-side verification call. Everything else is user interface.
| Version | User experience | Returns | Best for |
|---|---|---|---|
| v2 Checkbox | "I'm not a robot", sometimes an image challenge | Pass / fail | Login, registration, contact forms |
| v2 Invisible | Nothing, unless suspicious | Pass / fail | Forms where a checkbox hurts conversion |
| v3 | Nothing, ever | Score 0.0–1.0 | Scoring across a whole site |
| Enterprise | Configurable | Score + reasons | High-value flows; paid beyond a free tier |
Note that v3 never blocks anything. It hands you a probability and leaves the decision to you — which is more flexible and considerably more work.
Register at google.com/recaptcha/admin. You get a site key (public, goes in your HTML) and a secret key (server only, never in client code or your repository).
Add every domain you use, including localhost for development. A token generated on a domain not on the list fails verification with invalid-keys.
<form method="post" action="/contact"> <label for="email">Email</label> <input type="email" id="email" name="email" required /> <label for="message">Message</label> <textarea id="message" name="message" required></textarea> <div class="g-recaptcha" data-sitekey="<?= htmlspecialchars(RECAPTCHA_SITE_KEY) ?>"></div> <button type="submit">Send</button> </form> <script src="https://www.google.com/recaptcha/api.js" async defer></script>
<?php
declare(strict_types=1);
function verifyRecaptcha(string $token, string $remoteIp = null): array
{
if ($token === '') {
return ['ok' => false, 'error' => 'missing-token'];
}
$ch = curl_init('https://www.google.com/recaptcha/api/siteverify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_POSTFIELDS => http_build_query(array_filter([
'secret' => RECAPTCHA_SECRET_KEY,
'response' => $token,
'remoteip' => $remoteIp,
])),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $status !== 200) {
// Google unreachable. Decide deliberately: fail open or fail closed.
return ['ok' => false, 'error' => 'verification-unavailable'];
}
$result = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
return [
'ok' => ($result['success'] ?? false) === true,
'errors' => $result['error-codes'] ?? [],
'raw' => $result,
];
}
// In your form handler
$check = verifyRecaptcha($_POST['g-recaptcha-response'] ?? '', $_SERVER['REMOTE_ADDR']);
if (!$check['ok']) {
$errors[] = 'Please complete the verification and try again.';
} else {
processForm($_POST);
}
Use a short timeout. Without one, an outage at Google means every form submission on your site hangs for the default socket timeout.
Decide explicitly what happens when verification is unavailable. Failing closed blocks real users during an outage; failing open lets bots through. For a contact form, failing open with extra logging is usually the right call. For account registration, fail closed.
v3 runs silently and scores each interaction from 0.0 (almost certainly a bot) to 1.0 (almost certainly human).
<script src="https://www.google.com/recaptcha/api.js?render=<?= RECAPTCHA_SITE_KEY ?>"></script>
<script>
document.querySelector('#contact-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.currentTarget;
// Tokens expire after 2 minutes — generate at submit, not on page load.
const token = await grecaptcha.execute('<?= RECAPTCHA_SITE_KEY ?>', { action: 'contact' });
form.querySelector('input[name="recaptcha_token"]').value = token;
form.submit();
});
</script>
Server side you now get a score and an action to check:
$check = verifyRecaptcha($_POST['recaptcha_token'] ?? '');
$data = $check['raw'] ?? [];
$score = (float) ($data['score'] ?? 0);
$action = $data['action'] ?? '';
// Verify the action matches — otherwise a token from a low-risk page
// (a homepage view) could be replayed against a high-risk endpoint.
if (!$check['ok'] || $action !== 'contact') {
reject('Verification failed.');
}
if ($score >= 0.7) {
processForm($_POST); // confident
} elseif ($score >= 0.3) {
processForm($_POST, ['flag_for_review' => true]); // uncertain — accept but flag
} else {
presentFallbackChallenge(); // likely bot — v2 checkbox as a second step
}
Three things about v3 that catch people out:
action. Without it, tokens are interchangeable between endpoints.| Code | Meaning |
|---|---|
missing-input-secret | Secret key not sent |
invalid-input-secret | Wrong secret, or site/secret keys swapped |
timeout-or-duplicate | Token expired (2 min) or already used — tokens are single-use |
invalid-input-response | Malformed token, or generated for a different site key |
browser-error | Client-side failure, often an ad blocker |
timeout-or-duplicate is the one you will hit most. Tokens cannot be reused, so if your handler validates other fields first and re-renders the form on error, the old token is already spent — the second submission always fails. Verify reCAPTCHA last, after your other validation passes, or refresh the widget when you re-render.
reCAPTCHA sends data about your visitors to Google and sets cookies. Under GDPR that is a third-party transfer requiring disclosure, and arguably consent — which is awkward, because a consent banner in front of your anti-spam is not much use.
Two alternatives with the same integration shape:
https://challenges.cloudflare.com/turnstile/v0/siteverify and the flow is otherwise identical to the code above.For a low-traffic contact form, consider whether you need a CAPTCHA at all. A honeypot field plus a submission-time check stops the overwhelming majority of naive bots with no third party and no user friction:
<!-- Hidden from users, visible to naive bots --> <div style="position:absolute;left:-9999px" aria-hidden="true"> <label>Leave this field empty<input type="text" name="website" tabindex="-1" autocomplete="off" /></label> </div> <input type="hidden" name="form_loaded_at" value="<?= time() ?>" />
if (!empty($_POST['website'])) {
exit; // bot filled the honeypot
}
if (time() - (int) $_POST['form_loaded_at'] < 3) {
exit; // submitted impossibly fast
}
Use position: absolute; left: -9999px rather than display: none — some bots specifically skip fields hidden the obvious way. And keep aria-hidden and tabindex="-1" so screen reader and keyboard users never encounter it.
Image challenges are a genuine barrier. The audio alternative is unreliable and unusable for anyone with both visual and hearing impairments. If you must use a visible CAPTCHA, always offer another route to complete the task — a phone number, an email address, anything. v3 and Turnstile are considerably better here because most users never face a challenge at all.
action and treat the score as a signal, not a verdict.Advertisement
Written by
Amit VermaFounder / Senior Software Engineer
Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.
More articles by AmitYou might also like
We ship software that scales. Let's work together.