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 in the hidden field is already dead. Siteverify will reject it.
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 to load. Retry below.')
}
turnstile.reset() throws away the stale token and runs the widget again. Reset after expiry, 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.
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. Nothing burns trust like a form that eats a long message because a challenge script failed to load.
The error callback also fires when the network blocks challenges.cloudflare.com. Corporate proxies and aggressive content blockers do this. Show a message that explains what to do next, not a dead button.
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