I'v followed the docs one-to-one but superform won't let me submit since cf-turnstile-response is always empty as shown in screenshot below. Not sure what I missed. I didn't change responseFieldName or any other option except the size prop. This happens with both dummy and real keys. Also I can't wrap my head around why we are binding with reset and resetting on onUpdate event.
schema.ts
import { z } from 'zod/v4';
export const schema = z.object({
email: z.email('Please enter a valid email.'),
password: z.string().min(1, 'Password is required.'),
'cf-turnstile-response': z.string().nonempty('Please complete turnstile')
});
+page.svelte (truncutated)
<script lang="ts">
//////////////////////////////
import { Turnstile } from 'svelte-turnstile';
let { data } = $props();
// Call this to reset the turnstile
let reset = $state<() => void>();
const form = superForm(data.form, {
validators: zod4Client(schema),
delayMs: 400,
onResult: async ({ result }) => {
if (result.type === 'success' && result.data?.user) {
// Set user in global store and redirect
userStore.setUser(result.data.user);
const redirectTo = $page.url.searchParams.get('redirectTo');
const redirectUrl = redirectTo ? decodeURIComponent(redirectTo) : '/explore';
goto(redirectUrl);
}
},
onUpdated() {
// When the form is updated, we reset the turnstile
reset?.();
}
});
const { form: formData, enhance, submitting, message } = form;
</script>
<section class="space-y-8">
//////////////////////////////////
<Card variant="form" classes="p-6">
<form class="w-full space-y-4" method="POST" use:enhance>
<Field {form} name="email">
<Control>
{#snippet children({ props })}
<label class="label">
<span class="label-text">Email</span>
<input
class="input"
{...props}
type="email"
bind:value={$formData.email}
placeholder="john@example.com"
data-testid="email-input"
/>
</label>
{/snippet}
</Control>
<Description class="sr-only"
>Provide a valid email address for account verification and communication.</Description
>
<FieldErrors class="text-error-700-300" />
</Field>
///////////////////////////////////
<Turnstile siteKey={data.publicTurnstileKey} size="flexible" bind:reset />
<button
type="submit"
class="btn flex w-full items-center justify-center gap-2 preset-filled"
disabled={$submitting}
data-testid="login-submit-btn"
>
Login
</button>
</form>
</Card>
//////////////////////
{#if !data.isProd}
<SuperDebug data={$formData} />
{/if}
</section>
+page.server.ts (truncutated)
import type { PageServerLoad } from './$types';
import { schema } from './schema';
import { superValidate, message } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import { fail, redirect } from '@sveltejs/kit';
import { SESSION_COOKIE_NAME, SessionRepo } from '$lib/repos/session';
import { UserRepo } from '$lib/repos/user';
import { getPublicTurnstileKey, getSecretTurnstileKey, isProduction } from '$lib/utils/env';
import { validateToken } from '$lib/server/turnstile';
export const load: PageServerLoad = async ({ locals, platform }) => {
if (locals.user) {
throw redirect(303, '/explore');
}
return {
isProd: isProduction(platform),
publicTurnstileKey: getPublicTurnstileKey(platform),
form: await superValidate(zod4(schema))
};
};
export const actions = {
default: async ({ request, platform, cookies, locals, url }) => {
const form = await superValidate(request, zod4(schema));
const sessionRepo = new SessionRepo(platform);
const userRepo = new UserRepo(platform);
if (!form.valid) {
return fail(400, { form });
}
const { email, password } = form.data;
const token = form.data['cf-turnstile-response'];
const secret = getSecretTurnstileKey(platform);
const { success } = await validateToken(token, secret);
if (!success) {
return message(form, 'Invalid captcha');
}
const user = await userRepo.verify(email, password);
if (!user) {
return message(form, 'Invalid email or password');
}
const session = await sessionRepo.create(user);
if (!session) {
return message(form, 'Failed to create session');
}
cookies.set(SESSION_COOKIE_NAME, session.token, {
path: '/',
expires: session.expiresAt,
httpOnly: true,
secure: isProduction(platform),
sameSite: 'strict',
maxAge: Math.max((session.expiresAt.getTime() - Date.now()) / 1000)
});
locals.user = user;
throw redirect(303, redirectUrl);
}
};
package.json
{
"name": "silroad",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "wrangler dev",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test",
"deploy": "pnpm run build && wrangler deploy",
"cf-typegen": "wrangler types ./src/worker-configuration.d.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.49.1",
"@skeletonlabs/skeleton": "^3.1.7",
"@skeletonlabs/skeleton-svelte": "^1.3.1",
"@sveltejs/adapter-cloudflare": "^7.0.0",
"@sveltejs/enhanced-img": "^0.7.1",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.1.0",
"@vitest/browser": "^3.2.3",
"drizzle-kit": "^0.31.4",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"playwright": "^1.53.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-turnstile": "^0.11.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.20.0",
"typescript-svelte-plugin": "^0.3.49",
"vite": "^7.0.4",
"vite-plugin-devtools-json": "^0.2.0",
"vitest": "^3.2.3",
"vitest-browser-svelte": "^0.1.0",
"wrangler": "^4.27.0",
"zod": "^4.0.10"
},
"pnpm": {
"onlyBuiltDependencies": [
"@tailwindcss/oxide",
"esbuild",
"sharp",
"workerd"
]
},
"dependencies": {
"@faker-js/faker": "^9.9.0",
"@lucide/svelte": "^0.536.0",
"drizzle-orm": "^0.44.4",
"formsnap": "^2.0.1",
"sveltekit-superforms": "^2.27.1",
"sveltekit-top-loader": "^0.1.0"
}
}
I'v followed the docs one-to-one but superform won't let me submit since
cf-turnstile-responseis always empty as shown in screenshot below. Not sure what I missed. I didn't changeresponseFieldNameor any other option except thesizeprop. This happens with both dummy and real keys. Also I can't wrap my head around why we are binding withresetand resetting ononUpdateevent.schema.ts+page.svelte(truncutated)+page.server.ts(truncutated)package.json{ "name": "silroad", "private": true, "version": "0.0.1", "type": "module", "scripts": { "dev": "vite dev", "build": "vite build", "preview": "wrangler dev", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", "lint": "prettier --check . && eslint .", "test:unit": "vitest", "test": "npm run test:unit -- --run && npm run test:e2e", "test:e2e": "playwright test", "deploy": "pnpm run build && wrangler deploy", "cf-typegen": "wrangler types ./src/worker-configuration.d.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio" }, "devDependencies": { "@eslint/compat": "^1.2.5", "@eslint/js": "^9.18.0", "@playwright/test": "^1.49.1", "@skeletonlabs/skeleton": "^3.1.7", "@skeletonlabs/skeleton-svelte": "^1.3.1", "@sveltejs/adapter-cloudflare": "^7.0.0", "@sveltejs/enhanced-img": "^0.7.1", "@sveltejs/kit": "^2.22.0", "@sveltejs/vite-plugin-svelte": "^6.0.0", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/vite": "^4.0.0", "@types/node": "^24.1.0", "@vitest/browser": "^3.2.3", "drizzle-kit": "^0.31.4", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-svelte": "^3.0.0", "globals": "^16.0.0", "playwright": "^1.53.0", "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.11", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "svelte-turnstile": "^0.11.0", "tailwindcss": "^4.0.0", "typescript": "^5.8.3", "typescript-eslint": "^8.20.0", "typescript-svelte-plugin": "^0.3.49", "vite": "^7.0.4", "vite-plugin-devtools-json": "^0.2.0", "vitest": "^3.2.3", "vitest-browser-svelte": "^0.1.0", "wrangler": "^4.27.0", "zod": "^4.0.10" }, "pnpm": { "onlyBuiltDependencies": [ "@tailwindcss/oxide", "esbuild", "sharp", "workerd" ] }, "dependencies": { "@faker-js/faker": "^9.9.0", "@lucide/svelte": "^0.536.0", "drizzle-orm": "^0.44.4", "formsnap": "^2.0.1", "sveltekit-superforms": "^2.27.1", "sveltekit-top-loader": "^0.1.0" } }