Server validation
Verify context and prevent replay
Check hostname and action where configured and understand the five-minute, single-use token lifecycle.
Siteverify tells you more than pass or fail. Read the whole response:
{
"success": true,
"challenge_ts": "2026-08-03T12:15:30.000Z",
"hostname": "flaviocopes.com",
"action": "contact-form",
"error-codes": []
}
A Turnstile token expires after 300 seconds, and it can be validated once. A replayed or expired token fails with timeout-or-duplicate in error-codes. That single-use behavior is your replay protection. If an attacker captures a token a real user already spent, the second validation fails on its own.
This only works if you call Siteverify exactly once per token and act on that result. Validate a token, store “verified” in a session, reuse that flag for later requests, and you reopened the replay window you just closed.
Check the context
When the design includes them, check that hostname and action match the form you expect:
const outcome = await result.json()
const valid =
outcome.success &&
outcome.hostname === 'flaviocopes.com' &&
outcome.action === 'contact-form'
You set the action on the widget with data-action="contact-form". Without this check, a token solved on a low-value form can be spent against a high-value endpoint that shares the same site key. The newsletter form and the password reset form should not accept each other’s tokens.
The hostname check catches tokens solved on another domain the widget allows.
Turnstile is one signal
Keep validating the form data. Keep authenticating the user, enforcing authorization, and rate-limiting abuse. Turnstile is one signal, not a complete anti-fraud system. A patient human with a real browser passes the challenge and can still submit garbage.
Now prove the replay defense works. Validate the same test token twice and check that the second path does not perform the action. The first call returns success: true. The second returns success: false with timeout-or-duplicate. If your handler does the protected work both times, it is trusting a local variable instead of the verification result. Fix that before shipping.
Lesson completed