Server validation
Verify context and prevent replay
Check hostname and action where configured and understand the five-minute, single-use token lifecycle.
8 minute lesson
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 currently expires after 300 seconds and can be validated once. A replay or expired token fails with timeout-or-duplicate in error-codes. That single-use behavior is your replay protection: when 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 its result. Validating a token, storing “verified” in a session, and reusing that flag for later requests reopens the replay window you just closed.
Check the context
Check that the returned hostname and action match the expected form when those fields are part of the design:
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 hostname check catches tokens solved on another domain the widget allows.
Turnstile is one signal
Continue to validate the form data, authenticate the user, enforce authorization, and rate-limit 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. Try validating the same test token twice and confirm 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 performs the protected work both times, it is trusting a local variable instead of the verification result. Fix that before shipping.
Lesson completed