Using Cloudflare Turnstile on an Astro form
By Flavio Copes
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 an Astro form to prevent spam and form submission abuse. The code works the same in Astro 7.
Turnstile has two halves. A widget on the page gets 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, because 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 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
If the endpoint is an Astro page, it has to render on demand. A prerendered page is built ahead of time, so it never sees the POST request. You need a server adapter, and export const prerender = false at the top of the page, unless the whole site already uses output: '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
}
}
Notice that we verify first and 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 may have expired. By default the widget fetches a new one on its own (refresh-expired defaults to auto). If that refresh fails, or you turned it off, siteverify rejects the old token 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 failed, or a network error interrupted it
// show a retry message, don't leave a dead button
}
turnstile.reset() discards the stale token and runs the widget again. With the default auto setting the widget already does that on its own, so here it’s a safety net. You need it if you set data-refresh-expired='manual'.
The error callback fires when the challenge fails or a network error interrupts it, so give the user a message that explains what to do next. If a content blocker stops the Turnstile script from loading at all, no callback runs and the form goes out without a token. Your server check catches that case: siteverify answers with a missing-input-response error code.
Also preserve the user’s form data when verification fails. Nobody wants to type a long message again because a challenge expired.
Want me to talk about your product? You can sponsor this site.
Related posts about astro: