Using Cloudflare Turnstile on a Astro form

By

Learn how to protect an Astro form with Cloudflare Turnstile, embedding the widget with your site key and verifying the token via the siteverify endpoint.

~~~

Here’s how I used Cloudflare Turnstile on a Astro form to prevent spam and form submission abuse.

Turnstile has two halves. A widget on the page obtains a token from the visitor’s browser. Your server sends that token to Cloudflare’s siteverify endpoint and only trusts the submission if the check passes.

You need both. The widget alone proves nothing, because a bot can POST to your endpoint without ever loading your page.

Set up the keys

Set Turnstile up in the Cloudflare panel first, and grab the TURNSTILE_SITE_KEY and TURNSTILE_SITE_SECRET variables, put them in .env or anywhere you manage env vars.

The site key is public and ends up in your HTML. The secret must stay on the server. In Astro, be careful with the PUBLIC_ prefix: any env variable named PUBLIC_SOMETHING gets inlined into the client bundle. Don’t use it for the secret.

Add the widget to the Astro component

Load the Turnstile script and render the widget inside the form:

<script
    is:inline
    src='https://challenges.cloudflare.com/turnstile/v0/api.js'
    defer
    async></script>

<form method='post'>
 ...
	<div
	  class='cf-turnstile'
	  data-sitekey={import.meta.env
	    .TURNSTILE_SITE_KEY ||
	    process.env.TURNSTILE_SITE_KEY}>
	</div>

  <input
    type='submit'
    value='Login'
  />
</form>

The is:inline directive matters. It tells Astro to leave the script tag alone instead of bundling it.

When the challenge passes, Turnstile adds a hidden field named cf-turnstile-response to the form. That field holds the token your server will verify.

Verify the token on the server

On the server endpoint (might be same page, or not):

export async function processTurnstile(
  cf_turnstile_response: string
) {
  const url =
    'https://challenges.cloudflare.com/turnstile/v0/siteverify'

  const requestBody = new URLSearchParams({
    secret:
      import.meta.env.TURNSTILE_SITE_SECRET ||
      process.env.TURNSTILE_SITE_SECRET,
    response: cf_turnstile_response
  })

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: requestBody.toString()
  })

  const data = await response.json()

  return data.success
}

if (Astro.request.method === 'POST') {
  const formData = await Astro.request.formData()

  const email = formData.get('email')?.toString() || ''
  const password =
    formData.get('password')?.toString() || ''

	const is_valid_turnstile = await processTurnstile(
    formData.get('cf-turnstile-response')?.toString() || ''
  )

  if (!is_valid_turnstile) {
    console.log('Invalid turnstile')
  } else {
		//valid, do something
  }
}

Note the order: verify first, act second. The protected work (creating the account, sending the email) only runs after data.success comes back true.

Handle expiry and errors

A token lives about five minutes and can be spent once. If someone fills your form, gets distracted, and submits later, the token in the hidden field is dead and siteverify rejects it with a timeout-or-duplicate error code.

Wire up the lifecycle callbacks so the page recovers instead of failing silently:

<div
  class='cf-turnstile'
  data-sitekey={import.meta.env.TURNSTILE_SITE_KEY}
  data-expired-callback='onExpired'
  data-error-callback='onWidgetError'>
</div>
function onExpired() {
  turnstile.reset()
}

function onWidgetError() {
  // the challenge script failed to load or run
  // show a retry message, don't leave a dead button
}

turnstile.reset() discards the stale token and runs the widget again. The error callback also fires when a corporate proxy or content blocker blocks challenges.cloudflare.com, so give the user a message that explains what to do next.

One more thing: preserve the user’s form data when verification fails. Nothing burns trust like a form that eats a long message because a challenge expired.

Tagged: Astro · All topics
~~~

Related posts about astro: