The Problem
Bot protection is essential for any public facing form. Cloudflare Turnstile offers a privacy friendly alternative to traditional CAPTCHAs: no image grids, no traffic analysis, just a lightweight challenge that runs in the background.
When you pair it with SvelteKit's use:enhance for progressive form enhancement, you hit a subtle issue. Turnstile tokens are single use. After a successful submission, the old token is consumed. The next submission attempt fails because the widget still holds the spent token. The page does not reload (that is the whole point of use:enhance), so the widget never gets a chance to issue a fresh one.
This guide walks through a complete SvelteKit integration with server side validation, client side token refresh, and multiple submission support.
Server Side Validation
Turnstile verification must happen on the backend. A client side check alone can be bypassed.
Backend (+page.server.ts)
import { SECRET_TURNSTILE_KEY } from '$env/static/private';
async function validateToken(token: string): Promise<boolean> {
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
secret: SECRET_TURNSTILE_KEY,
response: token
})
});
const data = await response.json();
return data.success;
}
export const actions = {
default: async ({ request }) => {
const formData = await request.formData();
const token = formData.get('cf-turnstile-response')?.toString() || '';
if (!token || !(await validateToken(token))) {
return { success: false, message: 'Verification failed. Please try again.' };
}
return { success: true, message: 'Form submitted successfully.' };
}
};
A few things worth noting here.
Turnstile returns the token as cf-turnstile-response by default. That field name is configurable if you need to avoid collisions.
The siteverify endpoint expects URL encoded form data, not JSON. This is a common gotcha when using fetch with the default JSON content type.
Store the secret key in a .env file as SECRET_TURNSTILE_KEY and reference it through SvelteKit's $env/static/private module. Never expose it to the client.
Frontend Integration
The client side uses the svelte-turnstile package which wraps the Turnstile widget as a Svelte component.
npm install svelte-turnstile
Frontend (+page.svelte)
<script lang="ts">
import { Turnstile } from 'svelte-turnstile';
import { enhance } from '$app/forms';
let captchaKey = $state(0);
let { form } = $props();
$effect(() => {
if (form) {
captchaKey += 1;
form = null;
}
});
</script>
<form method="POST" use:enhance>
{#key captchaKey}
<Turnstile siteKey={import.meta.env.VITE_TURNSTILE_SITEKEY} />
{/key}
<button type="submit">Submit</button>
</form>
Why the Key Block Works
SvelteKit's use:enhance intercepts the form submission and updates the page without a full navigation. The Turnstile widget holds a token that has now been spent.
The {#key captchaKey} block tells Svelte to destroy and recreate its children whenever captchaKey changes. Incrementing the counter inside the $effect forces a fresh Turnstile widget to mount with a new token.
No setTimeout, no mount/unmount races, just a counter increment.
Environment Variables
You need two keys from the Cloudflare Turnstile dashboard.
VITE_TURNSTILE_SITEKEY=0x4AAAAAA... # public, exposed to browser
SECRET_TURNSTILE_KEY=0x4AAAAAA... # private, server only
The VITE_ prefix makes the variable available to client side Vite bundles. The un-prefixed version is loaded through $env/static/private and never leaves the server.
Production Considerations
Token expiry. Turnstile tokens expire after 300 seconds. If a user fills out a long form, the token may become invalid by the time they hit submit. Consider implementing a periodic refresh mechanism for forms with long completion times.
Rate limiting. The /siteverify endpoint does not have built in rate limiting. Implement your own per IP or per session limits to prevent abuse.
Accessibility. Turnstile runs a non interactive challenge by default. If the challenge fails, it falls back to a visible widget. Test this flow with keyboard only navigation and screen readers.
Multiple forms on one page. Each Turnstile instance needs a unique widget ID. The svelte-turnstile component handles this internally, but verify that your page layout does not share instances between forms.
Summary
- Turnstile tokens are single use and require a fresh widget after each submission.
- Server side validation is mandatory. Never trust the client alone.
- The mount/unmount pattern in
$effectsolves the token refresh problem cleanly. - Environment variables keep your keys separated by environment.
This setup gives you a secure, bot protected form that works with SvelteKit's progressive enhancement.
Top comments (2)
Instead of the 0 ms setTimeout to recreate the captcha, can't you replace the
{#if showCaptcha}with{#key showCaptcha}and just doshowCaptcha = !showCaptchain the$effect? Obviously also changingshowCaptchaname, since it doesn't fit anymoreGood catch. Using {#key} with a counter is cleaner. I updated the article to use captchaKey += 1 in the $effect and {#key captchakey} in the template. Leaving behind the setTimeout trick I was using before.