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.
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 browser, or a script, posts to the server function's URL.
- Start's CSRF middleware refuses posts it can't prove came from your own origin.
- The visitor middleware reads the client IP and Origin header and passes them on as context.
- The validator normalises the input before the handler sees it.
- The handler takes a token from that IP's bucket. With none left, it returns a try-later result.
- It verifies the Turnstile token, then forwards the fields to Ikiform, which stores them and scores the honeypot.
Project setup
src/
routes/
support.tsx
lib/
use-turnstile.ts
server/
bucket.ts
visitor.ts
support.ts
start.ts
.envVITE_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 throughimport.meta.env. The secrets have no prefix, so they exist only on the server asprocess.env. - The test keys pass on any host, including
localhost. Swap in real ones per environment.
Function middleware for the client IP
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:
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
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.
actionmust besupport. A token minted for another widget with your site key is refused.- The honeypot
_gotchais forwarded, not checked here, so a filled one is stored as spam where you can review it.
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:
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 };
}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:
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-srcneeds'unsafe-inline'; it still blocks scripts from any host you didn't list. - Turnstile draws its widget in an iframe from
challenges.cloudflare.com, whichframe-srcmust allow. - The CSRF
filterlimits 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:
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
- Run the dev server with the test keys and send a request. It appears under Submissions. Without a plan, 50
localhosttests a month are free. - Send four requests quickly. The fourth returns the try-later message with a number of seconds.
- Set
TURNSTILE_SECRET_KEYto the always-fail secret. Every request shows the human-check error. - Replay the server function call from the network tab with
curland noOrigin. The CSRF middleware refuses it. - 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
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.
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.
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.
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.