Stop contact form spam

Stop contact form spam8 min read

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.

Preet SutharFounder, Ikiform

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.

The middleware chain in front of the TanStack Start server functionBefore your codeIn the server functionPOST/_serverFnfrom theform or ascriptCSRFmiddlewaresameorigin?visitormiddlewarecontext:ip, originvalidatortrims, capslengthstokenbucket3 tries, 1back every20 ssiteverifyaction issupportIkiformstores,scoreshoneypotresult:trylateryestoken lefthumanemptyThe middleware chain in front of the TanStack Start server functionBefore your codeIn the server functionPOST/_serverFnfrom the form or ascriptCSRFmiddlewaresame origin?visitormiddlewarecontext: ip, originvalidatortrims, caps lengthstoken bucket3 tries, 1 backevery 20 ssiteverifyaction is supportIkiformstores, scoreshoneypotresult:trylateryestoken lefthumanempty
  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
src/
  routes/
    support.tsx
  lib/
    use-turnstile.ts
  server/
    bucket.ts
    visitor.ts
    support.ts
  start.ts
.env
.env
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
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
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 shows, and keep the same take(key) shape.

The server function

src/server/support.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 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
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
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
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

HostCLIENT_IP_HEADERLimiter
Your own Node server or containerWhatever your proxy setsIn memory works
Vercelx-forwarded-forRedis
Netlifyx-nf-client-connection-ipRedis
Cloudflare Workerscf-connecting-ipWorkers 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
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 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

ProblemWhat to do
Cross-site posts reach server functions after adding src/start.tsA custom src/start.ts replaces the default CSRF middleware. Add createCsrfMiddleware() to requestMiddleware, as shown.
Every visitor shares one bucketCLIENT_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 VercelEach function instance has its own memory. Move the bucket to Redis.
The widget renders twice in developmentReact's Strict Mode mounts effects twice. The cleanup removes the first widget, so production shows one.

Questions

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