Two facts from Google's
official reCAPTCHA v3 documentation
explain most SPA integration pain. First, a token is
valid for two minutes. Second, a token
can be verified only once. Google's
own guidance draws the conclusion for you: if you are
protecting an action, call
grecaptcha.execute()
when the user takes the action — not when the
page loads. Classic multi-page sites get this behavior
almost for free, because the widget loads with the form
and the form posts away within a click or two. A
single-page app breaks every one of those assumptions:
the script loads once at boot, components mount long
before users act, and "the page" never reloads to
refresh anything.
The Anti-Pattern: Fetching the Token at Mount
The broken version is seductive because it looks like
idiomatic React: a useEffect on the form
component that calls execute() once,
stores the token in state, and slips it into the
payload at submit time.
// Looks clean. Fails for every slow user.
useEffect(() => {
grecaptcha.execute(SITE_KEY, { action: "signup" })
.then(setToken);
}, []);
The token's two-minute clock starts at mount. Any user
who reads the form, fumbles a password, or switches
tabs submits a corpse, and your server gets a
verification failure for a perfectly human visitor.
This single mistake is common enough that the Drupal
reCAPTCHA v3 module tracked it as its own issue —
literally titled
"Recaptcha token should not be retrieved on page
load"
— and it fills the
google/recaptcha issue tracker
with v3 integrations that "randomly" fail. The related
server-side symptom has its own error code:
timeout-or-duplicate, which we dissect in
the
token-expiry guide. Seeing it spike is the signature of mount-time
token fetching.
The Correct Pattern: Execute Inside the Submit Handler
Make token acquisition the first step of submission itself. The token is then at most a network round-trip old when your backend verifies it:
async function handleSubmit(values) {
const token = await grecaptcha.execute(SITE_KEY, {
action: "signup",
});
await api.post("/signup", { ...values, token });
}
-
One execute per attempt. Tokens
are single-use; if the user retries after a
validation error, fetch a fresh token. Reusing the
old one guarantees
timeout-or-duplicate. - Disable the button while in flight. Double-clicks produce two submissions racing to verify tokens, and the loser looks like an attack in your logs.
-
Name actions per flow
(
signup,checkout,password_reset) and verify the action name server-side. If verification fails anyway, work through our token-debugging checklist before blaming the user.
v2 Widgets in React: Render, Reset, Unmount
The checkbox widget adds a second dimension: it is a DOM artifact that React does not know about. Three rules keep it stable in an SPA:
-
Render explicitly into a ref. Load
api.js?render=explicitonce, then callgrecaptcha.render(divRef.current, {...})when the form component mounts, and keep the returned widget ID. -
Reset instead of re-rendering.
After a failed submit or a consumed token, call
grecaptcha.reset(widgetId). Callingrender()twice on the same container throws the infamous "reCAPTCHA has already been rendered in this element" — which is also what you will see in development under React 18's StrictMode, where effects run twice by design. Guard the render call or store the widget ID outside the effect. -
Handle token expiry in place. A
checked checkbox is not a token in hand: the v2
response also expires after roughly two minutes,
firing the widget's
expired-callback. Wire that callback to clear your stored response and re-enable verification, so the user re-checks instead of submitting a dead token.
Wrapper libraries — react-google-recaptcha
for v2, react-google-recaptcha-v3 for
score-based flows — implement exactly this lifecycle
and are fine choices; just treat them as dependencies
that track an external service and keep them updated.
And whichever path you choose, develop against the
official test keys rather than production ones — our
localhost and test-keys guide
covers that setup.
Design for the Slow User, Not the Demo
Every fix above is a special case of one principle: verification freshness must be tied to the action, not the page. SPAs decouple "page loaded" from "user acted" — sometimes by twenty minutes — so any verification artifact created at load time is stale by design. That goes beyond CAPTCHAs: CSRF tokens, payment intents, and signed upload URLs all rot in long-lived views. Token lifecycle is also a fair criterion when choosing a verification layer in the first place — behavioral systems such as rCAPTCHA assess the session continuously rather than minting a two-minute artifact the frontend must babysit, though any provider's integration still deserves the slow-user test: open the form, wait five minutes, then submit.
People Also Ask: SPA Integration FAQ
How long is a reCAPTCHA token valid?
Two minutes, per Google's official documentation, and
each token can be verified only once. Both v2 responses
and v3 tokens are subject to expiry; the v2 widget
fires expired-callback when its response
lapses.
Should I call grecaptcha.execute() on page load?
Not for protecting actions. Google's guidance is to execute when the user takes the action you are protecting — in an SPA, that means inside the submit handler, with a fresh token per attempt.
Why do I get timeout-or-duplicate errors in my React app?
Either the token was older than two minutes when your server verified it (fetched at mount, submitted later) or the same token was verified twice (retry logic or double-submit reusing a consumed token). Fetch at submit time and never reuse tokens.
Why does reCAPTCHA break only in development with React 18?
StrictMode intentionally double-invokes effects in
development, so unguarded render() calls
hit the same container twice and throw. Guard against
re-rendering an already-rendered container; the issue
disappears in production builds but the guard is
correct in both.
Conclusion
SPA CAPTCHA failures feel random because they are time-dependent: fast users never see them, slow users always do, and your tests are fast users. The repair is almost boring once stated — request the token when the user acts, spend it immediately, fetch a fresh one per retry, and let explicit render plus reset manage v2 widgets across React's lifecycle. Build the slow-user walkthrough into your release checklist and token expiry stops being a bug report and becomes a design constraint you already handled.
Sources & Further Reading
- Google for Developers: reCAPTCHA v3 — token validity and execute-on-action guidance
- Google for Developers: explicit rendering and the JavaScript widget API
- Drupal.org: "Recaptcha token should not be retrieved on page load" (discovery signal)
- google/recaptcha #302: timeout-or-duplicate with v3 (discovery signal)
- google/recaptcha #340: v3 integration issues (discovery signal)