Skip to content

cf-turnstile-response always empty in superform #50

@hammadmajid

Description

@hammadmajid

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.


Image

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"
	}
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions