Client integration

Handle success, expiry, errors, and accessibility

Keep the form usable while tokens expire, widgets fail, users navigate by keyboard, and networks block a challenge resource.

A successful challenge gives you a token. But tokens expire, and they are single-use. A token lives about five minutes. If someone opens your form, gets distracted, and submits later, the token may have expired. By default the widget fetches a new one on its own (refresh-expired defaults to auto). If that refresh fails, or you turned it off, Siteverify rejects the old token with a timeout-or-duplicate error code.

So wire up the lifecycle callbacks. Your page should react instead of failing silently:

<div
  class="cf-turnstile"
  data-sitekey="0x4AAAAAAABkMYinukE8nzKd"
  data-callback="onToken"
  data-expired-callback="onExpired"
  data-error-callback="onWidgetError"
></div>
function onToken(token) {
  document.querySelector('#submit').disabled = false
}

function onExpired() {
  turnstile.reset()
}

function onWidgetError() {
  showRetryMessage('The verification failed. Retry below.')
}

turnstile.reset() throws away the stale token and runs the widget again. Reset after a failed server validation and after a completed attempt. A token that already passed Siteverify once cannot be spent again, so a second submit needs a new one. After expiry the widget already refreshes on its own with the default auto setting, so the reset in onExpired() is a safety net. You need it if you set data-refresh-expired="manual".

Keep the form recoverable

Do not leave the submit button disabled forever after an error. Show a clear retry state and keep what the user typed. Nobody wants to type a long message again because a challenge failed.

The error callback fires when the challenge fails or a network error interrupts it. Show a message that explains what to do next, not a dead button.

If a corporate proxy or a content blocker stops the Turnstile script from loading at all, no callback runs, because the callbacks live in that script. The form goes out without a token, and your server check catches it: Siteverify answers with a missing-input-response error code. Handle that code on the server with the same retry message.

Accessibility is still your job

Turnstile does not replace accessible labels and error messages. Test keyboard navigation, zoom, screen-reader output, slow networks, and script blocking. Tab through the whole form and check that focus lands somewhere sensible after a widget reset. A reset that drops focus to the top of the page is a small thing that makes keyboard users give up.

Now rehearse the failure. Fill the form, force expiry by calling turnstile.reset() from the console (or wait out the five minutes), then submit. The user should recover without retyping anything: the error is announced, the fields keep their values, and a fresh token lets the second attempt go through.

Lesson completed