Server validation

Call Siteverify and fail closed

Send the secret and browser token from the server, validate the response, and reject missing, invalid, or unverifiable submissions.

The server side of Turnstile is one HTTP call. You POST two fields to the Siteverify endpoint: secret, your secret key, and response, the token the browser sent you. You can also send remoteip, the visitor’s IP, and an idempotency_key if you want to retry the same validation safely.

The hard part is not the call. It’s what you do when the call doesn’t go well.

Fail closed

Fail closed means: when in doubt, reject. A missing token is a rejection. A network error talking to Siteverify is a rejection. A response that isn’t valid JSON is a rejection. The only way to get through is a real success: true.

The opposite, letting the request through when the check couldn’t run, is called failing open. It feels friendly to users. It also means an attacker only has to make Siteverify unreachable, or send garbage, to bypass the check.

I wrap the whole thing in a helper that returns a boolean and never throws:

async function verifyTurnstile(token, env) {
  if (!token) return false

  try {
    const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
      method: 'POST',
      body: new URLSearchParams({ secret: env.TURNSTILE_SECRET, response: token })
    })
    const outcome = await result.json()
    return outcome.success === true
  } catch {
    return false
  }
}

Every path that isn’t a confirmed success ends in false. That’s the whole point of the shape.

Verify first, then work

Do not process the form first and validate afterward. If you insert the row, send the email, and then check the token, a failed check leaves the side effect behind. Call the helper at the top of the handler and return early on false.

Keep the details in your logs, not in the response. Log the error-codes array Siteverify returns so you can debug. Tell the user something generic and useful instead, like “We couldn’t verify your submission, please try again.”

Write this helper in a Worker and test four cases: no token, a made-up token, a Siteverify timeout (stub fetch to throw), and a real success. The first three must return false and perform no side effect.

Verify the token from trusted server code:

const result = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
  method: 'POST',
  body: new URLSearchParams({ secret: env.TURNSTILE_SECRET, response: token })
}).then(response => response.json())

Continue only when success is true and the expected context matches. Reject missing, expired, duplicate, and failed-verification tokens. Apply rate limits and normal authorization after this check.

Lesson completed