reCAPTCHA in React and Single-Page Apps: Stop Tokens From Expiring Before Submit

reCAPTCHA in React and Single-Page Apps: Stop Tokens From Expiring Before Submit

It passes every test you run, because you fill forms in ten seconds. Real users open the checkout, answer a text, compare prices in another tab, and come back to a form whose CAPTCHA token quietly died at the two-minute mark. Here is why single-page apps hit token expiry far more than classic sites, the execute-on-action pattern that fixes it, and the React lifecycle traps waiting in between.

rCAPTCHA Team
rCAPTCHA Team
June 10, 2026 · 11 min read

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

Try rCAPTCHA

Experience the technology discussed in this article.

Learn More →

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:

  1. Render explicitly into a ref. Load api.js?render=explicit once, then call grecaptcha.render(divRef.current, {...}) when the form component mounts, and keep the returned widget ID.
  2. Reset instead of re-rendering. After a failed submit or a consumed token, call grecaptcha.reset(widgetId). Calling render() 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.
  3. 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

rCAPTCHA Blog
rCAPTCHA Blog

Insights on web security and bot detection

More from this blog →

Responses

No responses yet. Be the first to share your thoughts!