# TanStack Start form spam protection with Turnstile

> Protect a TanStack Start form from spam: function middleware for the client IP, a token bucket, Turnstile verified in a server function, and a CSP.

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

Add function middleware that reads the client IP and origin once and passes them to every server function as context. In the server function, validate the input, take a token from a per-IP bucket, verify the Turnstile token with siteverify and forward the fields with the honeypot. Keep the CSRF middleware Start installs, and add it back yourself if you create `src/start.ts`.

## What's different in TanStack Start

A server function is a same-origin RPC endpoint. Start protects it from other sites with a CSRF middleware that checks `Sec-Fetch-Site`, `Origin` or `Referer`, and that stops a page elsewhere from posting through a visitor's browser. It doesn't stop a script, which can send whatever headers it likes. So the spam checks still belong inside the function.

Start's own building block for this is middleware. Function middleware runs before the validator and handler, can read request headers, and hands values to the handler through `context`. Write the IP lookup once and every form on the site gets it.

Diagram: The middleware chain in front of the TanStack Start server function

1. The browser, or a script, posts to the server function's URL.
2. Start's CSRF middleware refuses posts it can't prove came from your own origin.
3. The visitor middleware reads the client IP and Origin header and passes them on as context.
4. The validator normalises the input before the handler sees it.
5. The handler takes a token from that IP's bucket. With none left, it returns a try-later result.
6. It verifies the Turnstile token, then forwards the fields to Ikiform, which stores them and scores the honeypot.

Each layer is cheaper than the next. A refused post never reaches siteverify or Ikiform.

## Project setup

`Files`:

```text
src/
  routes/
    support.tsx
  lib/
    use-turnstile.ts
  server/
    bucket.ts
    visitor.ts
    support.ts
  start.ts
.env
```

`.env`:

```bash
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-forwarded-for
```

- Vite only exposes variables prefixed with `VITE_` to the browser, read through `import.meta.env`. The secrets have no prefix, so they exist only on the server as `process.env`.
- The test keys pass on any host, including `localhost`. Swap in real ones per environment.

## Function middleware for the client IP

`src/server/visitor.ts`:

```ts
import { createMiddleware } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-forwarded-for";

export const visitor = createMiddleware({ type: "function" }).server(async ({ next }) => {
  const ip = getRequestHeader(HEADER)?.split(",")[0]?.trim() || "unknown";
  return next({ context: { ip, origin: getRequestHeader("origin") ?? null } });
});
```

Set `CLIENT_IP_HEADER` to the header your host overwrites: `x-forwarded-for` on Vercel, `x-nf-client-connection-ip` on Netlify, which Netlify staff recommend though its docs only describe `context.ip` in Functions, and `cf-connecting-ip` on Cloudflare. On your own Node server behind nginx, use the header nginx sets and make sure the app isn't reachable around it. The middleware also passes the visitor's `Origin` on, because the server-side fetch to Ikiform sends none and Ikiform reads it to recognise free `localhost` tests.

## A token bucket per IP

A fixed window lets a script send a full allowance at the end of one minute and another at the start of the next. A token bucket refills one try at a time, so someone fixing a typo waits seconds, not a minute:

`src/server/bucket.ts`:

```ts
const CAPACITY = 3;
const REFILL_MS = 20_000;
const buckets = new Map<string, { tokens: number; at: number }>();

export function take(key: string, now = Date.now()) {
  const saved = buckets.get(key) ?? { tokens: CAPACITY, at: now };
  const tokens = Math.min(CAPACITY, saved.tokens + (now - saved.at) / REFILL_MS);
  if (tokens < 1) {
    buckets.set(key, { tokens, at: now });
    return { ok: false, retryAfter: Math.ceil(((1 - tokens) * REFILL_MS) / 1000) };
  }
  if (buckets.size > 50_000) buckets.clear();
  buckets.set(key, { tokens: tokens - 1, at: now });
  return { ok: true, retryAfter: 0 };
}
```

This counter lives in memory, which is exact on a single long-running server, such as `node .output/server/index.mjs` in a container. On Vercel or Netlify each function instance keeps its own map, so the real limit is unknown. Move the bucket to Redis there, as the [rate limiting page](https://ikiform.com/guides/spam-protection/rate-limiting.md) shows, and keep the same `take(key)` shape.

## The server function

`src/server/support.ts`:

```ts
import { createServerFn } from "@tanstack/react-start";
import { take } from "./bucket";
import { visitor } from "./visitor";

const TOPICS = ["billing", "bug", "account", "other"];
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export interface SupportInput {
  email: string;
  topic: string;
  message: string;
  _gotcha: string;
  token: string;
}

export type SupportResult = { ok: true } | { ok: false; field?: "email" | "message"; message: string };

async function isHuman(token: string, ip: string) {
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
    });
    const verdict = (await response.json()) as { success: boolean; action?: string };
    return verdict.success && verdict.action === "support";
  } catch {
    return false;
  }
}

export const sendSupportRequest = createServerFn({ method: "POST" })
  .middleware([visitor])
  .validator((input: SupportInput) => ({
    email: String(input.email ?? "").trim(),
    topic: TOPICS.includes(input.topic) ? input.topic : "other",
    message: String(input.message ?? "").trim().slice(0, 5000),
    _gotcha: String(input._gotcha ?? ""),
    token: String(input.token ?? "").slice(0, 2048),
  }))
  .handler(async ({ data, context }): Promise<SupportResult> => {
    if (!EMAIL.test(data.email)) return { ok: false, field: "email", message: "Enter an address we can reply to." };
    if (data.message.length < 10) return { ok: false, field: "message", message: "Tell us a little more." };

    const limit = take(context.ip);
    if (!limit.ok) return { ok: false, message: `Too many requests. Try again in ${limit.retryAfter} seconds.` };
    if (!(await isHuman(data.token, context.ip))) return { ok: false, message: "We couldn't confirm you're human." };

    const { token: _token, ...fields } = data;
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        ...(context.origin ? { Origin: context.origin } : {}),
      },
      body: JSON.stringify(fields),
    }).catch(() => null);
    return response?.ok ? { ok: true } : { ok: false, message: "Your request didn't send. Try again." };
  });
```

- The validator runs before the handler and only normalises input. Field errors come back as a result the form can show next to the right input.
- Cheap checks first: the email and length checks cost nothing, the bucket costs a map lookup, siteverify costs a network call.
- `action` must be `support`. A token minted for another widget with your site key is refused.
- The honeypot `_gotcha` is forwarded, not checked here, so a filled one is stored as spam where you can review it.

> 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 route and the widget

The widget renders explicitly from a small hook. A module-level promise loads the script once, even when the route mounts several times during client navigation, and the token arrives through Turnstile's `callback`:

`src/lib/use-turnstile.ts`:

```ts
import { useCallback, useEffect, useRef, useState } from "react";

interface Api {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): 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 = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
    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 useTurnstile(sitekey: string, action: string) {
  const ref = useRef<HTMLDivElement>(null);
  const api = useRef<Api | null>(null);
  const widget = useRef<string | null>(null);
  const [token, setToken] = useState("");

  useEffect(() => {
    let live = true;
    load().then((turnstile) => {
      if (!live || !ref.current) return;
      api.current = turnstile;
      widget.current = turnstile.render(ref.current, {
        sitekey,
        action,
        callback: setToken,
        "expired-callback": () => setToken(""),
      });
    });
    return () => {
      live = false;
      if (widget.current) api.current?.remove(widget.current);
      widget.current = null;
    };
  }, [sitekey, action]);

  const reset = useCallback(() => {
    setToken("");
    if (widget.current) api.current?.reset(widget.current);
  }, []);

  return { ref, token, reset };
}
```

`src/routes/support.tsx`:

```tsx
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { useTurnstile } from "../lib/use-turnstile";
import { type SupportResult, sendSupportRequest } from "../server/support";

export const Route = createFileRoute("/support")({ component: Support });

function Support() {
  const send = useServerFn(sendSupportRequest);
  const turnstile = useTurnstile(import.meta.env.VITE_TURNSTILE_SITE_KEY, "support");
  const [result, setResult] = useState<SupportResult | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    setPending(true);
    try {
      setResult(
        await send({
          data: {
            email: String(form.get("email") ?? ""),
            topic: String(form.get("topic") ?? ""),
            message: String(form.get("message") ?? ""),
            _gotcha: String(form.get("_gotcha") ?? ""),
            token: turnstile.token,
          },
        }),
      );
    } catch {
      setResult({ ok: false, message: "Couldn't reach the server. Try again." });
    } finally {
      setPending(false);
      turnstile.reset();
    }
  }

  if (result?.ok) return <p role="status">Got it. We'll reply by email within a day.</p>;
  const error = result && !result.ok ? result : null;

  return (
    <form onSubmit={onSubmit} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" aria-invalid={error?.field === "email"} />
      <label htmlFor="topic">Topic</label>
      <select id="topic" name="topic" defaultValue="other">
        <option value="billing">Billing</option>
        <option value="bug">Something is broken</option>
        <option value="account">My account</option>
        <option value="other">Something else</option>
      </select>
      <label htmlFor="message">What happened?</label>
      <textarea id="message" name="message" rows={6} aria-invalid={error?.field === "message"} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hp" />
      <div ref={turnstile.ref} />
      {error ? <p role="alert">{error.message}</p> : null}
      <button disabled={pending || !turnstile.token}>{pending ? "Sending" : "Send request"}</button>
    </form>
  );
}
```

- The button stays disabled until Turnstile hands over a token, so nobody submits an empty one.
- `turnstile.reset()` runs after every attempt. A token works once and lives 300 seconds, so a retry needs a fresh one.
- This form needs JavaScript. Server functions can take plain form posts through their `.url`, but the Turnstile widget needs a browser anyway.

## Security headers in src/start.ts

Start has no CSP option of its own. Global request middleware in `src/start.ts` can set one on every response. Creating that file turns off the CSRF middleware Start installs by default, so add it back first:

`src/start.ts`:

```ts
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { setResponseHeaders } from "@tanstack/react-start/server";

const policy = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
  "frame-src https://challenges.cloudflare.com",
  "object-src 'none'",
  "base-uri 'self'",
  "frame-ancestors 'none'",
].join("; ");

const securityHeaders = createMiddleware().server(async ({ next }) => {
  setResponseHeaders(new Headers({ "Content-Security-Policy": policy }));
  return next();
});

export const startInstance = createStart(() => ({
  requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }), securityHeaders],
}));
```

- Start's server-rendered HTML includes inline scripts. Without a nonce setup, `script-src` needs `'unsafe-inline'`; it still blocks scripts from any host you didn't list.
- Turnstile draws its widget in an iframe from `challenges.cloudflare.com`, which `frame-src` must allow.
- The CSRF `filter` limits the check to server functions, so normal page loads aren't affected.

## Deploying

| Host | CLIENT_IP_HEADER | Limiter |
| --- | --- | --- |
| Your own Node server or container | Whatever your proxy sets | In memory works |
| Vercel | `x-forwarded-for` | Redis |
| Netlify | `x-nf-client-connection-ip` | Redis |
| Cloudflare Workers | `cf-connecting-ip` | Workers rate limiting binding |

Vercel documents that it overwrites `x-forwarded-for` with the visitor's address. List each production and preview hostname in the Turnstile widget before you deploy.

## Let Ikiform run the checks

If you don't want to run Turnstile keys and a limiter yourself, drop the server function and post from the browser. In `onSubmit`, replace the `send` call with a challenge and a submit, where `box` is a ref on an empty `div` that replaces the widget:

`src/routes/support.tsx`:

```ts
import { challenge, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

const turnstileToken = await challenge(FORM_ID, { container: box.current ?? undefined });
const result = await submit(FORM_ID, new FormData(event.currentTarget), { turnstileToken });
```

Set the form's **Spam check** to **Ikiform**, choose the Strict **Rate limit** of 2 every 10 minutes per visitor, and add your domain under **Allowed domains**. Ikiform then runs the honeypot, Turnstile on ikiform.com, the per-IP limit and content scoring, and keeps what it catches for 30 days. Your CSP needs `https://ikiform.com` in `frame-src` and `connect-src` instead of Cloudflare. The [server setup guide](https://ikiform.com/docs/guides/server.md) covers keys if you keep the server function.

## Check it works

1. Run the dev server with the test keys and send a request. It appears under **Submissions**. Without a plan, 50 `localhost` tests a month are free.
2. Send four requests quickly. The fourth returns the try-later message with a number of seconds.
3. Set `TURNSTILE_SECRET_KEY` to the always-fail secret. Every request shows the human-check error.
4. Replay the server function call from the network tab with `curl` and no `Origin`. The CSRF middleware refuses it.
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 |
| --- | --- |
| Cross-site posts reach server functions after adding `src/start.ts` | A custom `src/start.ts` replaces the default CSRF middleware. Add `createCsrfMiddleware()` to `requestMiddleware`, as shown. |
| Every visitor shares one bucket | `CLIENT_IP_HEADER` names a header your host doesn't set, so every IP is `unknown`. Use the header from the table above. |
| The limit seems to reset at random on Vercel | Each function instance has its own memory. Move the bucket to Redis. |
| The widget renders twice in development | React's Strict Mode mounts effects twice. The cleanup removes the first widget, so production shows one. |

## Questions

### How do I get the client IP in a TanStack Start server function?

Read the header your host sets with getRequestHeader from @tanstack/react-start/server, ideally once in function middleware that passes it to the handler as context. Use x-forwarded-for on Vercel, x-nf-client-connection-ip on Netlify and cf-connecting-ip on Cloudflare.

### Do TanStack Start server functions have CSRF protection?

Yes. Start installs a CSRF middleware for server functions unless you define src/start.ts, in which case you add createCsrfMiddleware yourself. It blocks cross-site browser posts, not scripts, so spam checks are still needed.

### Can I rate limit TanStack Start in memory?

On one long-running Node server, yes. On serverless hosts each instance has its own memory, so use a shared store such as Redis or Cloudflare's rate limiting binding.

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