# 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.

Published 2026-10-25 · https://ikiform.com/guides/spam-protection/sveltekit

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.

Diagram: Where getClientAddress() gets the visitor's IP on each adapter

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

```text
src/
  env.ts
  lib/
    turnstile.ts
    server/
      spam.ts
  routes/
    contact/
      +page.server.ts
      +page.svelte
vite.config.ts
.env
```

`.env`:

```bash
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`:

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

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

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

```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](https://ikiform.com/docs/rate-limits.md) 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`:

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

```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](https://ikiform.com/guides/spam-protection/honeypot.md).

## 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`:

```bash
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`:

```bash
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.

| 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](https://ikiform.com/docs/guides/sveltekit.md) 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

| 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

### How do I get the client IP in SvelteKit?

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.

### Why does Turnstile fail on the second submit with use:enhance?

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.

### Does SvelteKit's CSP work with Turnstile?

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.

### Is Ikiform free?

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.

## More guides

- [Stop contact form spam](https://ikiform.com/guides/spam-protection.md): Stop contact form spam with layers that cost real people nothing: a honeypot, timing, content scoring, rate limits and a human check only where needed.
- [Cloudflare Turnstile](https://ikiform.com/guides/spam-protection/cloudflare-turnstile.md): Add Cloudflare Turnstile to a plain HTML form: the widget, server-side siteverify, test keys, and a way to run it with no keys or server of your own.
- [Rate limiting](https://ikiform.com/guides/spam-protection/rate-limiting.md): Rate limit form submissions per visitor with a shared store, answer 429 with Retry-After, and avoid locking out offices that share one IP.
- [Without a captcha](https://ikiform.com/guides/spam-protection/without-captcha.md): Stop most form spam with no captcha at all: a honeypot, a signed render time, a content score and a rate limit, added together instead of used alone.
- [Honeypot field](https://ikiform.com/guides/spam-protection/honeypot.md): Add a honeypot to any form that catches bots without tripping screen readers, keyboards or autofill, with the server check for every stack.
- [reCAPTCHA alternatives](https://ikiform.com/guides/spam-protection/recaptcha-alternatives.md): Compare reCAPTCHA alternatives for forms, from Turnstile and hCaptcha to proof-of-work and no captcha at all, then switch with a small, safe change.
