Skip to main content
Being Idea Innovations
Google reCAPTCHA with PHP: v2, v3 and the Verification Step Everyone Skips
Back to Blog

Google reCAPTCHA with PHP: v2, v3 and the Verification Step Everyone Skips

5 September 20186 min readPHP
Share:

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.

The mistake that makes reCAPTCHA useless

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.

Which version

VersionUser experienceReturnsBest for
v2 Checkbox"I'm not a robot", sometimes an image challengePass / failLogin, registration, contact forms
v2 InvisibleNothing, unless suspiciousPass / failForms where a checkbox hurts conversion
v3Nothing, everScore 0.0–1.0Scoring across a whole site
EnterpriseConfigurableScore + reasonsHigh-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.

Getting keys

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.

reCAPTCHA v2 in PHP

The form

<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>

The verification

<?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.

reCAPTCHA v3 and scores

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:

  • Do not treat 0.5 as a magic threshold. Google's default suggestion is a starting point. Log scores against real outcomes for a couple of weeks before you decide where to cut.
  • Always check the action. Without it, tokens are interchangeable between endpoints.
  • Never hard-block on a low score alone. Privacy-conscious users, VPN users, and people on shared corporate IPs score low while being entirely legitimate. Degrade to a challenge instead of refusing.

Error codes worth knowing

CodeMeaning
missing-input-secretSecret key not sent
invalid-input-secretWrong secret, or site/secret keys swapped
timeout-or-duplicateToken expired (2 min) or already used — tokens are single-use
invalid-input-responseMalformed token, or generated for a different site key
browser-errorClient-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.

Privacy and the alternatives

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:

  • Cloudflare Turnstile — free, no image puzzles, no cross-site tracking. Verification endpoint is https://challenges.cloudflare.com/turnstile/v0/siteverify and the flow is otherwise identical to the code above.
  • hCaptcha — drop-in compatible with reCAPTCHA's API shape, privacy-focused, free tier available.

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.

Accessibility

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.

Summary

  • The widget is decoration; server-side verification is the protection.
  • Keep the secret key server-side and out of version control.
  • Set a short timeout and decide deliberately whether to fail open or closed.
  • Verify reCAPTCHA last — tokens are single-use and expire in two minutes.
  • With v3, check the action and treat the score as a signal, not a verdict.
  • Consider Turnstile or a honeypot if the privacy cost is not worth it.
Share:

Written by

Amit Verma

Founder / 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 Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers