I Got "timeout-or-duplicate" Error Using reCAPTCHA v3: Fix
The most common mistake is generating the reCAPTCHA v3 token when the page loads and then sending it whenever the user finally clicks submit. If you got the "timeout-or-duplicate" error using reCAPTCHA v3, that is the first thing to check. There are three usual causes:
- The token expired. A v3 token is valid for two minutes, and many users take longer than that to fill in a form.
- The token was verified twice. Google accepts each token only once. A second
siteverifycall with the same token fails, even inside the same request. - The token was reused. Double clicks, AJAX retries, the back button, or repopulated old input can send the same token again.
Short answer: timeout-or-duplicate means Google received a token that is older than two minutes or has already been verified. Call grecaptcha.execute() inside the submit handler, not on page load. Then make sure your server calls siteverify exactly once per token. This removes the error in most cases.
All the code below uses one example: a Laravel contact form at /contact that posts to ContactController@send.
Quick Fix: Generate the Token at Submit Time
This is the broken version. It is very common in tutorials:
<script>
grecaptcha.ready(function () {
grecaptcha.execute('SITE_KEY', { action: 'contact' }).then(function (token) {
document.getElementById('g-recaptcha-response').value = token;
});
});
</script>
The token's two-minute clock starts on page load. A user who spends three minutes typing a message will always fail.
Instead, generate the token in the submit handler:
<form id="contact-form" method="POST" action="{{ route('contact.send') }}">
@csrf
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response">
<input type="text" name="name">
<textarea name="message"></textarea>
<button type="submit" id="contact-submit">Send</button>
</form>
<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script>
<script>
document.getElementById('contact-form').addEventListener('submit', function (e) {
e.preventDefault();
const form = this;
document.getElementById('contact-submit').disabled = true; // blocks double clicks
grecaptcha.ready(function () {
grecaptcha.execute('{{ config('services.recaptcha.site_key') }}', { action: 'contact' })
.then(function (token) {
document.getElementById('g-recaptcha-response').value = token;
form.submit(); // native submit, does not re-trigger this listener
});
});
});
</script>
Each submission now gets a fresh token that is only a few milliseconds old. Disabling the button also covers the double-click case. Calling form.submit() does not fire the submit event again, so the handler cannot loop.
Diagnosing the "timeout-or-duplicate" Error Cause by Cause
The siteverify response includes a challenge_ts field, which is the time the token was generated. You can use it to tell the causes apart. First, log the full response in the validation rule:
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Http;
class Recaptcha implements ValidationRule
{
public function __construct(
private string $action,
private float $minScore = 0.5,
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$response = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [
'secret' => config('services.recaptcha.secret_key'),
'response' => $value,
'remoteip' => request()->ip(),
])->json() ?? [];
logger()->info('reCAPTCHA verify', $response);
if (! ($response['success'] ?? false)
|| ($response['action'] ?? null) !== $this->action
|| ($response['score'] ?? 0) < $this->minScore) {
$fail('Captcha verification failed. Please try again.');
}
}
}
Google's documentation suggests 0.5 as a starting threshold. Adjust it based on the scores your own traffic produces.
Cause 1: Token Timed Out
How to confirm: Look for a log entry with error-codes: ["timeout-or-duplicate"]. Check whether the token is regenerated at submit time in your current JavaScript. If it is generated on page load, timeout-or-duplicate appears only on slow submissions, which is the giveaway.
Fix: Use the submit-time pattern from the Quick Fix. Single-page apps and Livewire components often fetch the token once in mounted() or init(), which has the same problem. Call execute() immediately before the request is sent.
Cause 2: Duplicate Verification on the Server
How to confirm: Count the reCAPTCHA verify log lines for a single form submission. If you see two lines, the first succeeds and the second fails with timeout-or-duplicate.
A typical way this happens:
// ContactController — verifies the token TWICE
public function send(Request $request, RecaptchaService $recaptcha)
{
$request->validate([
'g-recaptcha-response' => ['required', new Recaptcha('contact')], // call #1
]);
if (! $recaptcha->verify($request->input('g-recaptcha-response'))) { // call #2 fails
abort(422);
}
// ...
}
Other ways the same thing happens: the rule runs once in a middleware and again in a Form Request, or a sometimes/after hook validates the same field a second time.
Fix: Choose one place to verify. The validation rule is usually the cleanest choice. Then delete the other check:
public function send(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:100'],
'message' => ['required', 'string'],
'g-recaptcha-response' => ['bail', 'required', new Recaptcha('contact')],
]);
// send the message...
return back()->with('status', 'Message sent.');
}
Cause 3: Livewire or AJAX Re-validation
Livewire's validateOnly() and real-time validation run your rules every time a field updates. If the reCAPTCHA rule is part of that rule set, Google receives the same token many times.
How to confirm: You will see a reCAPTCHA verify log entry each time a field loses focus.
Fix: Run the reCAPTCHA rule only in the final submit() method and leave it out of the real-time rules. If your architecture really does need several checks, cache the result for each token:
use Illuminate\Support\Facades\Cache;
$passed = Cache::remember('recaptcha:' . sha1($token), 120, function () use ($token) {
return $this->callSiteverify($token); // returns bool
});
There is a tradeoff here, and it is a judgment call. Caching a success lets the same token pass again within those two minutes, which weakens Google's replay protection. You can tie the cache key to the session ID if that concerns you. For more on cache keys, see working with tagged items in Laravel Cache.
Cause 4: Token Reused After a Failed Submit
When server-side validation fails for another field, Laravel redirects back with old input. If your hidden field uses value="{{ old('g-recaptcha-response') }}", the already-used token is sent back to the server. AJAX libraries that retry on network errors, and the browser back button, cause the same problem.
How to confirm: The first submit succeeds or fails for an unrelated reason. Every following submit fails with timeout-or-duplicate.
Fix: Never repopulate the token field. The submit-time pattern overwrites the field anyway. For fetch/axios forms, call grecaptcha.execute() again before each retry.
If Nothing Worked: Gather Evidence
- Reproduce it directly. Copy a fresh token from the browser's Network tab and verify it twice with curl:
curl -s -X POST https://www.google.com/recaptcha/api/siteverify \
-d "secret=YOUR_SECRET_KEY" \
-d "response=PASTE_TOKEN_HERE"
The first call should return "success": true. The second should return timeout-or-duplicate. If the first call fails with a fresh token, the problem is not timing. Check the secret key and the domain settings in the reCAPTCHA admin console.
- Compare
challenge_tswith the request time instorage/logs/laravel.log. A gap longer than two minutes means the token timed out. A short gap means it was a duplicate.
- Check the Network tab to confirm the form body contains a new token on each attempt.
- Check that the request reaches Laravel at all. Very large multipart forms can drop fields silently. If
g-recaptcha-responseis missing from the request entirely, read fixing the PHP POST request capacity limit.
FAQ
How long is a reCAPTCHA v3 token valid?
Two minutes after it is generated, and it can be verified only once. Generate it right before the request is sent instead of storing it for later.
Can I refresh the reCAPTCHA v3 token on a timer?
Yes. Some developers call execute() every 90 seconds to keep a fresh token. It works, but it creates extra calls and still fails if the token is verified twice. Generating the token at submit time is simpler and more dependable.
Why does it work locally but fail in production?
Production often has extra layers that local setups do not: a queue job, middleware, or load-balancer retries. Any of these can verify or resend the token a second time. Log every siteverify call to find the duplicate.
Does timeout-or-duplicate mean my secret key is wrong?
No. A wrong secret returns invalid-input-secret. If you got the "timeout-or-duplicate" error using reCAPTCHA v3, the keys are working. The problem is when the token was generated or how many times it was verified.


