How to Solve Google v3 reCAPTCHA Timeout in PHP Forms

By HeadsNTail Published 8 min read PHP
How to Solve Google v3 reCAPTCHA Timeout in PHP Forms

Why does your reCAPTCHA v3 form work when you submit it quickly but fail when a user takes their time? It's because the token has already expired. If you've been searching for how to solve google v3 recaptcha timeout?, the fix is less about server settings and more about when your JavaScript asks Google for the token.

Short answer: A reCAPTCHA v3 token is only valid for two minutes, and Google accepts it only once. Most tutorials generate the token when the page loads, so slow users submit an expired token. Call grecaptcha.execute() inside the submit handler instead, and verify the token on the server exactly once.

Before you start, you'll need:

  • PHP with the cURL extension enabled (php -m should list curl)
  • A v3 site key and secret key from the reCAPTCHA admin console, with your domain added (add localhost too if you test locally)
  • Access to your project files and, on XAMPP, to php.ini

Everything below uses one example: a plain PHP contact form split across four files.

contact/
├── config.php      # keys and threshold
├── recaptcha.php   # verification function
├── contact.php     # the form
└── send.php        # handles the POST

Why the reCAPTCHA v3 Token Expires Before Submit

This part trips people up, so it's worth understanding before you change any code. The two-minute clock starts when grecaptcha.execute() returns the token. It doesn't start when the user clicks submit.

Here's the pattern most people copy:

grecaptcha.ready(function () {
    grecaptcha.execute('SITE_KEY', { action: 'contact' }).then(function (token) {
        document.getElementById('g-recaptcha-response').value = token;
    });
});

This runs as soon as the page loads. A user who reads your page, types a message, and submits three minutes later is sending a token that's already dead. Google replies with timeout-or-duplicate, and the user sees "verification failed" even though they did nothing wrong.

The fix is to change when the token is requested.

How to Solve Google v3 reCAPTCHA Timeout: The Walkthrough

Step 1: Put the keys in config.php

<?php
// contact/config.php
const RECAPTCHA_SITE_KEY  = 'your-site-key';
const RECAPTCHA_SECRET    = 'your-secret-key';
const RECAPTCHA_MIN_SCORE = 0.5;

Keep this file out of git, or read the values from environment variables. Google's documentation suggests 0.5 as a starting threshold. Adjust it once you've seen the scores your real traffic gets.

Step 2: Write one verification function in recaptcha.php

<?php
// contact/recaptcha.php

function verify_recaptcha(string $token, string $expectedAction): array
{
    if ($token === '') {
        return ['success' => false, 'error-codes' => ['missing-input-response']];
    }

    $ch = curl_init('https://www.google.com/recaptcha/api/siteverify');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query([
            'secret'   => RECAPTCHA_SECRET,
            'response' => $token,
            'remoteip' => $_SERVER['REMOTE_ADDR'] ?? '',
        ]),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
    ]);

    $body = curl_exec($ch);

    if ($body === false) {
        return ['success' => false, 'error-codes' => ['curl: ' . curl_error($ch)]];
    }

    $result = json_decode($body, true) ?? ['success' => false, 'error-codes' => ['bad-json']];

    if (($result['success'] ?? false)
        && (($result['action'] ?? '') !== $expectedAction
            || ($result['score'] ?? 0) < RECAPTCHA_MIN_SCORE)) {
        $result['success'] = false;
        $result['error-codes'][] = 'low-score-or-wrong-action';
    }

    return $result;
}

Why use cURL instead of file_get_contents()? Many hosts disable allow_url_fopen, and with cURL you get a readable error message when the request fails.

Step 3: Request the token at submit time in contact.php

<?php require __DIR__ . '/config.php'; ?>
<form id="contact-form" method="POST" action="send.php">
    <input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response">
    <input type="text" name="name" required>
    <textarea name="message" required></textarea>
    <button type="submit" id="contact-submit">Send</button>
</form>

<script src="https://www.google.com/recaptcha/api.js?render=<?= htmlspecialchars(RECAPTCHA_SITE_KEY) ?>"></script>
<script>
const SITE_KEY = '<?= htmlspecialchars(RECAPTCHA_SITE_KEY) ?>';

document.getElementById('contact-form').addEventListener('submit', function (e) {
    e.preventDefault();
    const form = this;
    document.getElementById('contact-submit').disabled = true;

    grecaptcha.ready(function () {
        grecaptcha.execute(SITE_KEY, { action: 'contact' }).then(function (token) {
            document.getElementById('g-recaptcha-response').value = token;
            form.submit(); // native submit, won't fire this listener again
        });
    });
});
</script>

Now every token is created a moment before it's sent. Disabling the button also stops double clicks, which would otherwise send the same token twice.

Step 4: Verify once in send.php

<?php
// contact/send.php
require __DIR__ . '/config.php';
require __DIR__ . '/recaptcha.php';

$result = verify_recaptcha($_POST['g-recaptcha-response'] ?? '', 'contact');
error_log('recaptcha: ' . json_encode($result));

if (!$result['success']) {
    http_response_code(422);
    exit('Verification failed. Please go back and try again.');
}

// Safe to process the message here.
echo 'Thanks, your message was sent.';

Call verify_recaptcha() in one place only. A second call with the same token always fails, and on the server that looks exactly like a timeout. The timeout-or-duplicate article covers the duplicate side in more detail.

Variations: AJAX Forms, Multiple Forms, and Token Refresh

AJAX or fetch() forms

The rule is the same here: request the token right before the request goes out, and request a new one for every retry.

async function sendContact(form) {
    const token = await new Promise(function (resolve) {
        grecaptcha.ready(function () {
            grecaptcha.execute(SITE_KEY, { action: 'contact' }).then(resolve);
        });
    });

    const data = new FormData(form);
    data.set('g-recaptcha-response', token);

    return fetch('send.php', { method: 'POST', body: data });
}

If your code retries failed requests, call sendContact() again rather than resending the old FormData.

Several forms on one page

Give each form its own listener and its own action name (contact, newsletter, and so on). Then pass the matching name to verify_recaptcha(). That way a token generated for the newsletter form can't be used to pass the contact check.

Refreshing the token on a timer

Some people refresh the token every 90 seconds so the hidden field always holds a fresh one:

function refreshToken() {
    grecaptcha.execute(SITE_KEY, { action: 'contact' }).then(function (token) {
        document.getElementById('g-recaptcha-response').value = token;
    });
}

grecaptcha.ready(function () {
    refreshToken();
    setInterval(refreshToken, 90000);
});

It works, and it's useful when a plugin requires the token to be in the field before submit. My opinion, though, is that it's the weaker choice. It calls Google over and over while the tab is open, and browsers often slow down timers in background tabs, so a user coming back to an old tab can still submit an expired token. Use submit-time execution whenever your code allows it.

Verify the Timeout Fix Works

  1. Open contact.php, fill in the form, and wait at least three minutes before submitting. That's the exact case that used to fail.
  2. Submit it, then open your PHP error log (C:\xampp\php\logs\php_error_log on a default XAMPP setup, or wherever error_log points on your server).
  3. Look for the recaptcha: line. You want "success":true, the expected "action":"contact", and a challenge_ts only a second or two before the request time.

If challenge_ts is minutes older than the request, the token is still being generated too early. Check that no page-load execute() call is left over somewhere in your JavaScript.

Common Errors While Setting Up reCAPTCHA v3 in PHP

"form.submit is not a function"

Your form has a field or button with name="submit", and it hides the form's native submit() method. Rename the field, or call HTMLFormElement.prototype.submit.call(form).

missing-input-response

The server got an empty token. Either the hidden input's name isn't g-recaptcha-response, or the form was sent before the promise resolved (for example, a jQuery $(form).submit() call that ran your other handlers again).

invalid-input-secret

The secret key is wrong or was copied with a stray space. Copy it again from the admin console.

"Invalid site key or not loaded in api.js"

The site key doesn't match the one in the render= URL, or the current domain isn't in the key's domain list.

curl: SSL certificate problem: unable to get local issuer certificate

This is common on XAMPP for Windows. PHP can't find a CA bundle. Download cacert.pem from the cURL website, save it somewhere like C:\xampp\php\extras\ssl\cacert.pem, then set this in php.ini and restart Apache:

curl.cainfo = "C:\xampp\php\extras\ssl\cacert.pem"
openssl.cafile = "C:\xampp\php\extras\ssl\cacert.pem"

Don't "fix" this error by turning off CURLOPT_SSL_VERIFYPEER. That gets rid of the error by removing the security check, which isn't really a fix.

Rolling Back If Something Breaks

Before you replace your old form code, commit it or save a copy of contact.php and send.php. If the new version causes trouble in production, you can restore the old files. For a quick emergency switch, add const RECAPTCHA_ENABLED = true; to config.php and wrap the check in send.php with if (RECAPTCHA_ENABLED) { ... }. Setting it to false turns verification off without a deploy. Just remember the form has no bot protection while it's off.

FAQ

How long is a Google reCAPTCHA v3 token valid?

Two minutes after grecaptcha.execute() returns it. It can also be verified only once. After either limit is reached, Google returns timeout-or-duplicate.

Can I increase the reCAPTCHA v3 token timeout?

No. The two-minute validity is set by Google, and there's no setting in the admin console or the API to change it. You work around it by generating the token later, not by making it last longer.

Why does reCAPTCHA v3 fail only for some users?

Usually those are the users who take more than two minutes on the form, or who click submit twice. Low scores are another cause. Log the full response so you can tell an expired token from a low score.

So, how to solve google v3 recaptcha timeout? in one line?

Move grecaptcha.execute() into the submit handler, so every token is created just before it's sent, and verify it once on the server. That handles the slow-user case, double clicks, and retries, without any timer or server-side change.

More PHP articles