Stop contact form spam

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.

Preet SutharFounder, Ikiform

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.

Where getClientAddress() gets the visitor's IP on each adapterform actioncallsgetClientAddress()your adapteradapter-nodesocket address, orADDRESS_HEADERadapter-vercelfrom Vercel's edgeadapter-netlifyfrom Netlify's edgeadapter-cloudflarefrom cf-connecting-ipWhere getClientAddress() gets the visitor's IP on each adapterform actioncallsgetClientAddress()youradapteradapter-nodesocket address, orADDRESS_HEADERadapter-vercelfrom Vercel's edgeadapter-netlifyfrom Netlify's edgeadapter-cloudflarefromcf-connecting-ip
  1. The form action asks the request event for the client address.
  2. SvelteKit delegates the answer to the adapter you deploy with.
  3. adapter-node uses the socket's remote address unless you set ADDRESS_HEADER and XFF_DEPTH.
  4. The Vercel, Netlify and Cloudflare adapters read the address their platform supplies, so there is nothing to configure.
Behind nginx or a load balancer, adapter-node sees the proxy's address for every visitor until you set ADDRESS_HEADER, and one shared address turns a per-IP limit into a site-wide one.

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

Files
src/
  env.ts
  lib/
    turnstile.ts
    server/
      spam.ts
  routes/
    contact/
      +page.server.ts
      +page.svelte
vite.config.ts
.env
.env
PUBLIC_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_token
src/env.ts
import { 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 has public: true, so it's the only one $app/env/public can hand to the browser. Anything in a server directory, such as src/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.
vite.config.ts
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

src/lib/server/spam.ts
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. reset comes back in milliseconds and becomes whole seconds to wait.
  • dev from $app/env skips the hostname check locally, because test tokens carry a dummy hostname.
  • Siteverify answers timeout-or-duplicate for a token older than 300 seconds or already used, which is why the page resets the widget.

The form action

src/routes/contact/+page.server.ts
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 FormData is forwarded, honeypot included, minus the token. Ikiform scores a filled honeypot and stores the message as spam.
  • The visitor's Origin is passed on, because a server-side fetch sends none and Ikiform reads it to recognise free localhost tests.
The server-side versions post with a secret key, so Ikiform skips Allowed domains and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom rate limit for a form you post through a server, and keep IKIFORM_SECRET_KEY in server-only environment variables.

The widget as a Svelte action

src/lib/turnstile.ts
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

src/routes/contact/+page.svelte
<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.
  • resetTurnstile runs 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 .hp class 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:

Terminal
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node build

Post 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:

Environment
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1

SvelteKit'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.

AdapterClient IPSecrets
adapter-nodeADDRESS_HEADER and XFF_DEPTHProcess environment
adapter-vercelAutomaticProject environment variables
adapter-netlifyAutomaticSite environment variables
adapter-cloudflareAutomaticwrangler 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

  1. Run vite dev with the test keys and send a message. It appears under Submissions. A workspace without a plan accepts 50 localhost tests a month.
  2. Send a second message without reloading. It succeeds, which shows the widget reset worked.
  3. Send six messages within a minute. The sixth shows how many seconds to wait.
  4. Behind your proxy, log getClientAddress() from two networks. You see two different addresses, not your proxy's.
  5. 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

ProblemWhat to do
Every visitor is rate limited together on adapter-nodegetClientAddress() returns your proxy's address. Set ADDRESS_HEADER and XFF_DEPTH.
The second submit always fails the human checkThe widget kept its used token. Call resetTurnstile in the use:enhance callback after update.
The build fails because a page with actions is prerenderedThe 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 violationscript-src or frame-src is missing https://challenges.cloudflare.com in csp.directives in vite.config.ts.

Questions

Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.