Stop contact form spam7 min read
SvelteKit form spam protection with Turnstile
Protect a SvelteKit form from spam: a form action with getClientAddress, a per-IP limit, Turnstile behind use:enhance, and a CSP that allows it.
Do the checks in a form action: limit by getClientAddress(), verify the Turnstile token with siteverify, then forward the fields with the honeypot. Render the widget with a Svelte action and reset it in the use:enhance callback, because each token works once. Allow challenges.cloudflare.com in the csp option of the sveltekit() plugin, and on adapter-node tell SvelteKit which proxy header holds the real IP.
What's different in SvelteKit
SvelteKit hands you the visitor's IP directly: getClientAddress() on the request event. Where that value comes from depends on the adapter, and on adapter-node the default is wrong behind any proxy. Get that right and the rest of the checks fit neatly into a form action.
- The form action asks the request event for the client address.
- SvelteKit delegates the answer to the adapter you deploy with.
- adapter-node uses the socket's remote address unless you set ADDRESS_HEADER and XFF_DEPTH.
- The Vercel, Netlify and Cloudflare adapters read the address their platform supplies, so there is nothing to configure.
The other SvelteKit detail is use:enhance. It submits without a page reload, which is nicer for people but means Turnstile never re-renders on its own. The widget has to be reset by hand after every answer.
Project setup
src/
env.ts
lib/
turnstile.ts
server/
spam.ts
routes/
contact/
+page.server.ts
+page.svelte
vite.config.ts
.envPUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_tokenimport { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
PUBLIC_TURNSTILE_SITE_KEY: { public: true },
TURNSTILE_SECRET_KEY: {},
IKIFORM_SECRET_KEY: {},
UPSTASH_REDIS_REST_URL: {},
UPSTASH_REDIS_REST_TOKEN: {},
});- SvelteKit 3 reads variables you declare in
src/env.ts. Only the site key haspublic: true, so it's the only one$app/env/publiccan hand to the browser. Anything in aserverdirectory, such assrc/lib/server, can't be imported by client code at all; SvelteKit fails the build if you try. - Declared variables are read when the app starts unless you set
static: true, so a rotated key needs a restart, not a rebuild. The$env/*modules still work but are deprecated in SvelteKit 3. - Cloudflare's test keys pass on any host, including
localhost.
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
sveltekit({
adapter: adapter(),
csp: {
mode: "auto",
directives: {
"script-src": ["self", "https://challenges.cloudflare.com"],
"frame-src": ["https://challenges.cloudflare.com"],
"object-src": ["none"],
"base-uri": ["self"],
},
},
}),
],
});SvelteKit 3 takes its options in vite.config.ts, passed to the sveltekit() plugin; svelte.config.js is no longer read. With mode: "auto", SvelteKit adds nonces to its own inline scripts on server-rendered pages and hashes on prerendered ones, so the policy needs no 'unsafe-inline'. Turnstile loads from its host, which script-src allows, and draws an iframe that frame-src allows.
The server-only helpers
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { dev } from "$app/env";
import { TURNSTILE_SECRET_KEY, UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "$app/env/private";
const limiter = new Ratelimit({
redis: new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }),
limiter: Ratelimit.slidingWindow(5, "60 s"),
prefix: "contact",
});
export async function secondsToWait(ip: string) {
const { success, reset } = await limiter.limit(ip);
return success ? 0 : Math.max(1, Math.ceil((reset - Date.now()) / 1000));
}
export async function isHuman(token: string, ip: string, host: string) {
if (!token || token.length > 2048) return false;
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
});
const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
return verdict.success && verdict.action === "contact" && (dev || verdict.hostname === host);
} catch {
return false;
}
}- The limit is a sliding window in Redis, shared by every server instance, five messages a minute per IP.
resetcomes back in milliseconds and becomes whole seconds to wait. devfrom$app/envskips the hostname check locally, because test tokens carry a dummy hostname.- Siteverify answers
timeout-or-duplicatefor a token older than 300 seconds or already used, which is why the page resets the widget.
The form action
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { isHuman, secondsToWait } from "#lib/server/spam.js";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, getClientAddress, url }) => {
const ip = getClientAddress();
const wait = await secondsToWait(ip);
if (wait) return fail(429, { wait, error: `Too many messages. Try again in ${wait} seconds.` });
const form = await request.formData();
const values = {
name: String(form.get("name") ?? "").trim(),
email: String(form.get("email") ?? "").trim(),
message: String(form.get("message") ?? "").trim(),
};
if (values.message.length < 10) return fail(400, { values, error: "A few words more, please." });
if (!(await isHuman(String(form.get("cf-turnstile-response") ?? ""), ip, url.hostname))) {
return fail(403, { values, error: "We couldn't confirm you're human. Try again." });
}
form.delete("cf-turnstile-response");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: form,
}).catch(() => null);
if (!response?.ok) return fail(502, { values, error: "Your message didn't send. Try again." });
return { sent: true };
},
} satisfies Actions;- The limit runs before the body is parsed.
fail(429)keeps the visitor on the page with the message. - Every other failure returns
values, so the inputs refill after an error. - The original
FormDatais forwarded, honeypot included, minus the token. Ikiform scores a filled honeypot and stores the message as spam. - The visitor's
Originis passed on, because a server-side fetch sends none and Ikiform reads it to recognise freelocalhosttests.
IKIFORM_SECRET_KEY in server-only environment variables.The widget as a Svelte action
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
interface Api {
render(container: HTMLElement, options: { sitekey: string; action: string }): string;
reset(widgetId: string): void;
remove(widgetId: string): void;
}
let loading: Promise<Api> | undefined;
function load() {
loading ??= new Promise<Api>((resolve, reject) => {
const script = document.createElement("script");
script.src = SCRIPT;
script.async = true;
script.onload = () => {
const api = (window as Window & { turnstile?: Api }).turnstile;
if (api) resolve(api);
else reject(new Error("Turnstile did not load"));
};
script.onerror = () => reject(new Error("Turnstile did not load"));
document.head.append(script);
});
return loading;
}
export function turnstile(node: HTMLElement, sitekey: string) {
let api: Api | undefined;
load().then((loaded) => {
api = loaded;
node.dataset.widgetId = loaded.render(node, { sitekey, action: "contact" });
});
return {
destroy() {
if (node.dataset.widgetId) api?.remove(node.dataset.widgetId);
},
};
}
export async function resetTurnstile(node: HTMLElement | undefined) {
const id = node?.dataset.widgetId;
if (id) (await load()).reset(id);
}A Svelte action is the natural home for a third-party widget: it runs when the element mounts, and destroy runs when it leaves. The widget ID lives on the element, so the page can reset that exact widget.
The page
<script lang="ts">
import { enhance } from "$app/forms";
import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
import { resetTurnstile, turnstile } from "#lib/turnstile.js";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let box = $state<HTMLDivElement>();
let sending = $state(false);
</script>
{#if form?.sent}
<p role="status">Thanks. We'll reply by email.</p>
{:else}
<form
method="POST"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
await resetTurnstile(box);
};
}}
>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required value={form?.values?.message ?? ""}></textarea>
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<div bind:this={box} use:turnstile={PUBLIC_TURNSTILE_SITE_KEY}></div>
{#if form?.error}
<p role="alert">{form.error}</p>
{/if}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
{/if}update({ reset: false })applies the result and keeps what the visitor typed. The default would clear the form, as a browser does.resetTurnstileruns after every answer, success or not. The used token is gone and a fresh one is ready.- Without JavaScript, the form still posts and the action still runs, but no widget renders, so siteverify refuses it with a clear message.
- A page with form actions can't be prerendered, so this route always renders on the server. Other pages can stay prerendered.
- The
.hpclass hides the honeypot off-screen, as in the honeypot guide.
Test it locally
Under vite dev there is no adapter in front of the app, so getClientAddress() returns your loopback address and every local tab shares one bucket. That's fine for checking the message, not for checking the proxy setup. To test that, build with adapter-node and run the output with the same variables you'll use in production:
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node buildPost a few times with different X-Forwarded-For values. With XFF_DEPTH=1, SvelteKit reads the right-most address, the one your proxy would add, so a value the client invents on the left changes nothing.
Deploying
On your own server with adapter-node behind one proxy, tell SvelteKit where the real IP is:
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1SvelteKit's docs warn that headers are easy to spoof and that reading the left-most X-Forwarded-For address is the classic mistake. XFF_DEPTH counts trusted proxies from the right, so set it to the number of proxies you run, and make sure the app can't be reached around them. SvelteKit 3 dropped adapter-node's ORIGIN variable. If your proxy doesn't pass the original host, set paths.origin in the sveltekit() options so SvelteKit's own form check knows your public URL.
| Adapter | Client IP | Secrets |
|---|---|---|
adapter-node | ADDRESS_HEADER and XFF_DEPTH | Process environment |
adapter-vercel | Automatic | Project environment variables |
adapter-netlify | Automatic | Site environment variables |
adapter-cloudflare | Automatic | wrangler secret put |
Let Ikiform run the checks
To run none of this yourself, delete +page.server.ts and point the form's action at your Ikiform endpoint, without use:enhance. The page can then be prerendered like the rest of a static site.
Set the form's Spam check to Ikiform, so a post without a token gets a short check page on ikiform.com that runs Turnstile and sends the fields on. Choose the Strict Rate limit, 2 every 10 minutes per visitor, and list your domain under Allowed domains. Ikiform keeps caught spam for 30 days, so a false positive is a click to restore. The SvelteKit setup guide covers the endpoint and keys.
Check it works
- Run
vite devwith the test keys and send a message. It appears under Submissions. A workspace without a plan accepts 50localhosttests a month. - Send a second message without reloading. It succeeds, which shows the widget reset worked.
- Send six messages within a minute. The sixth shows how many seconds to wait.
- Behind your proxy, log
getClientAddress()from two networks. You see two different addresses, not your proxy's. - Post with the honeypot filled in,
_gotcha=bot. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
Gotchas
| Problem | What to do |
|---|---|
Every visitor is rate limited together on adapter-node | getClientAddress() returns your proxy's address. Set ADDRESS_HEADER and XFF_DEPTH. |
| The second submit always fails the human check | The widget kept its used token. Call resetTurnstile in the use:enhance callback after update. |
| The build fails because a page with actions is prerendered | The contact route inherited prerender = true from a layout. Pages with actions can't be prerendered; set export const prerender = false in its +page.server.ts. |
| The widget area is empty and the console reports a CSP violation | script-src or frame-src is missing https://challenges.cloudflare.com in csp.directives in vite.config.ts. |
Questions
Call getClientAddress() on the request event in a form action, load function or hook. The adapter supplies it. On adapter-node behind a proxy, set ADDRESS_HEADER and XFF_DEPTH or you get the proxy's address.
use:enhance doesn't reload the page, so the widget keeps the token it already used. Reset the widget in the enhance callback after every answer.
Yes. Add https://challenges.cloudflare.com to script-src and frame-src in csp.directives, which SvelteKit 3 takes in the sveltekit() plugin options in vite.config.ts. With mode auto, SvelteKit adds nonces or hashes for its own scripts.
No, there is no free plan. Testing is free: you can send 50 test submissions a month from localhost before you pay. To go live, Pro starts at $6/mo for 500 submissions, or you can pay once for 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.