# Turnstile을 이용한 TanStack Start 폼 스팸 방지

> 클라이언트 IP를 위한 함수 미들웨어, 토큰 버킷, 서버 함수에서 검증하는 Turnstile, CSP로 TanStack Start 폼을 스팸으로부터 보호하세요.

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

클라이언트 IP와 출처를 한 번 읽어 모든 서버 함수에 컨텍스트로 넘기는 함수 미들웨어를 추가하세요. 서버 함수에서는 입력을 검증하고, IP별 버킷에서 토큰을 하나 꺼내고, siteverify로 Turnstile 토큰을 검증한 뒤, 허니팟과 함께 필드를 전달합니다. Start가 설치하는 CSRF 미들웨어는 유지하고, `src/start.ts`를 만든다면 직접 다시 추가하세요.

## TanStack Start에서 다른 점

서버 함수는 동일 출처 RPC 엔드포인트입니다. Start는 `Sec-Fetch-Site`, `Origin`, `Referer`를 확인하는 CSRF 미들웨어로 다른 사이트로부터 서버 함수를 보호하며, 이 미들웨어는 다른 곳의 페이지가 방문자의 브라우저를 통해 POST하는 것을 막습니다. 원하는 헤더를 무엇이든 보낼 수 있는 스크립트는 막지 못합니다. 그래서 스팸 검사는 여전히 함수 안에 있어야 합니다.

이를 위한 Start의 기본 구성 요소는 미들웨어입니다. 함수 미들웨어는 검증기와 핸들러보다 먼저 실행되고, 요청 헤더를 읽을 수 있으며, `context`를 통해 핸들러에 값을 넘깁니다. IP 조회를 한 번만 작성하면 사이트의 모든 폼이 이를 사용합니다.

다이어그램: TanStack Start 서버 함수 앞의 미들웨어 체인

1. 브라우저나 스크립트가 서버 함수의 URL로 POST합니다.
2. Start의 CSRF 미들웨어는 여러분의 출처에서 왔다고 증명할 수 없는 POST를 거부합니다.
3. 방문자 미들웨어가 클라이언트 IP와 Origin 헤더를 읽어 컨텍스트로 넘깁니다.
4. 검증기가 핸들러보다 먼저 입력을 정규화합니다.
5. 핸들러가 해당 IP의 버킷에서 토큰을 하나 꺼냅니다. 남은 토큰이 없으면 나중에 다시 시도하라는 결과를 반환합니다.
6. Turnstile 토큰을 검증한 뒤 필드를 Ikiform으로 전달하고, Ikiform이 저장하고 허니팟 점수를 매깁니다.

각 계층은 다음 계층보다 저렴합니다. 거부된 POST는 siteverify나 Ikiform에 도달하지 않습니다.

## 프로젝트 설정

`파일`:

```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는 `VITE_` 접두사가 붙은 변수만 브라우저에 노출하며, `import.meta.env`로 읽습니다. 시크릿에는 접두사가 없으므로 서버에서 `process.env`로만 존재합니다.
- 테스트 키는 `localhost`를 포함해 어떤 호스트에서든 통과합니다. 환경마다 실제 키로 바꾸세요.

## 클라이언트 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 } });
});
```

`CLIENT_IP_HEADER`를 호스트가 덮어쓰는 헤더로 설정하세요. Vercel은 `x-forwarded-for`, Netlify는 `x-nf-client-connection-ip`(문서에는 Functions의 `context.ip`만 나와 있지만 Netlify 직원이 권장하는 헤더), Cloudflare는 `cf-connecting-ip`입니다. nginx 뒤에서 직접 운영하는 Node 서버라면 nginx가 설정하는 헤더를 쓰고, nginx를 우회해 앱에 접근할 수 없게 하세요. 미들웨어는 방문자의 `Origin`도 넘깁니다. Ikiform으로 가는 서버 측 fetch는 이 헤더를 보내지 않는데, Ikiform은 이를 읽어 무료 `localhost` 테스트를 알아보기 때문입니다.

## IP별 토큰 버킷

고정 구간에서는 스크립트가 한 분의 끝에 허용량 전부를 보내고 다음 분의 시작에 또 보낼 수 있습니다. 토큰 버킷은 시도를 하나씩 회복하므로, 오타를 고치는 사람은 1분이 아니라 몇 초만 기다리면 됩니다.

`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 };
}
```

이 카운터는 메모리에 있으며, 컨테이너에서 실행하는 `node .output/server/index.mjs`처럼 오래 실행되는 단일 서버에서는 정확합니다. Vercel이나 Netlify에서는 함수 인스턴스마다 맵이 따로 있어 실제 제한을 알 수 없습니다. 그런 곳에서는 [레이트 리밋 페이지](https://ikiform.com/ko/guides/spam-protection/rate-limiting.md)처럼 버킷을 Redis로 옮기고, 같은 `take(key)` 형태를 유지하세요.

## 서버 함수

`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." };
  });
```

- 검증기는 핸들러보다 먼저 실행되며 입력을 정규화하기만 합니다. 필드 오류는 폼이 해당 입력 옆에 표시할 수 있는 결과로 돌아옵니다.
- 저렴한 검사부터 합니다. 이메일과 길이 검사는 비용이 없고, 버킷은 맵 조회 한 번, siteverify는 네트워크 호출 한 번입니다.
- `action`은 `support`여야 합니다. 다른 위젯용으로 여러분의 site key로 발급된 토큰은 거부됩니다.
- 허니팟 `_gotcha`는 여기서 확인하지 않고 전달하므로, 채워진 경우 검토할 수 있는 스팸으로 저장됩니다.

> 서버 측 버전은 시크릿 키로 POST하므로 Ikiform은 **Allowed domains**와 자체 사람 확인을 건너뜁니다. 그러면 모든 POST가 서버의 IP에서 오고, 방문자당 분당 5건인 Standard 제한이 모든 방문자를 합쳐서 셉니다. 서버를 거쳐 POST하는 폼에는 Relaxed 또는 Custom [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요.

## 라우트와 위젯

위젯은 작은 훅에서 명시적으로 렌더링됩니다. 모듈 수준의 프라미스가 스크립트를 한 번만 불러오므로 클라이언트 내비게이션 중에 라우트가 여러 번 마운트되어도 괜찮으며, 토큰은 Turnstile의 `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>
  );
}
```

- Turnstile이 토큰을 건네줄 때까지 버튼은 비활성화되어 있으므로, 빈 토큰으로 제출하는 사람이 없습니다.
- `turnstile.reset()`은 시도할 때마다 실행됩니다. 토큰은 한 번만 쓸 수 있고 300초 동안 유효하므로, 재시도에는 새 토큰이 필요합니다.
- 이 폼에는 JavaScript가 필요합니다. 서버 함수는 `.url`을 통해 일반 폼 POST를 받을 수 있지만, Turnstile 위젯은 어차피 브라우저가 필요합니다.

## src/start.ts의 보안 헤더

Start에는 자체 CSP 옵션이 없습니다. `src/start.ts`의 전역 요청 미들웨어로 모든 응답에 CSP를 설정할 수 있습니다. 이 파일을 만들면 Start가 기본으로 설치하는 CSRF 미들웨어가 꺼지므로 먼저 다시 추가하세요.

`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가 서버에서 렌더링하는 HTML에는 인라인 스크립트가 들어 있습니다. nonce를 설정하지 않으면 `script-src`에 `'unsafe-inline'`이 필요하며, 그래도 목록에 없는 호스트의 스크립트는 여전히 막습니다.
- Turnstile은 `challenges.cloudflare.com`의 iframe에 위젯을 그리므로 `frame-src`가 이를 허용해야 합니다.
- CSRF `filter`가 검사를 서버 함수로 한정하므로 일반 페이지 로드에는 영향이 없습니다.

## 배포

| 호스트 | CLIENT_IP_HEADER | 리미터 |
| --- | --- | --- |
| 직접 운영하는 Node 서버나 컨테이너 | 프록시가 설정하는 헤더 | 메모리로 충분 |
| Vercel | `x-forwarded-for` | Redis |
| Netlify | `x-nf-client-connection-ip` | Redis |
| Cloudflare Workers | `cf-connecting-ip` | Workers 레이트 리밋 바인딩 |

Vercel은 `x-forwarded-for`를 방문자의 주소로 덮어쓴다고 문서화하고 있습니다. 배포하기 전에 모든 프로덕션 및 미리보기 호스트 이름을 Turnstile 위젯에 등록하세요.

## Ikiform에 검사 맡기기

Turnstile 키와 리미터를 직접 운영하고 싶지 않다면 서버 함수를 빼고 브라우저에서 POST하세요. `onSubmit`에서 `send` 호출을 챌린지와 제출로 바꾸세요. 여기서 `box`는 위젯을 대신하는 빈 `div`에 연결된 ref입니다.

`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 });
```

폼의 **Spam check**를 **Ikiform**으로 설정하고, **Rate limit**은 방문자당 10분에 2건인 Strict를 고르고, **Allowed domains**에 도메인을 추가하세요. 그러면 Ikiform이 허니팟, ikiform.com의 Turnstile, IP별 제한, 콘텐츠 점수를 실행하고, 걸러 낸 것을 30일 동안 보관합니다. CSP에는 Cloudflare 대신 `frame-src`와 `connect-src`에 `https://ikiform.com`이 필요합니다. 서버 함수를 유지한다면 키는 [서버 설정 가이드](https://ikiform.com/ko/docs/guides/server.md)에서 다룹니다.

## 작동 확인하기

1. 테스트 키로 개발 서버를 실행하고 요청을 보내세요. **Submissions**에 나타납니다. 플랜이 없어도 매월 `localhost` 테스트 50건은 무료입니다.
2. 요청 네 개를 빠르게 보내세요. 네 번째는 초 단위 대기 시간과 함께 나중에 다시 시도하라는 메시지를 반환합니다.
3. `TURNSTILE_SECRET_KEY`를 항상 실패하는 시크릿으로 설정하세요. 모든 요청에 사람 확인 오류가 표시됩니다.
4. 네트워크 탭의 서버 함수 호출을 `Origin` 없이 `curl`로 재전송하세요. CSRF 미들웨어가 거부합니다.
5. 허니팟을 채워 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| `src/start.ts`를 추가한 뒤 교차 사이트 POST가 서버 함수에 도달함 | 커스텀 `src/start.ts`는 기본 CSRF 미들웨어를 대체합니다. 예제처럼 `requestMiddleware`에 `createCsrfMiddleware()`를 추가하세요. |
| 모든 방문자가 하나의 버킷을 공유함 | `CLIENT_IP_HEADER`가 호스트가 설정하지 않는 헤더를 지정하고 있어 모든 IP가 `unknown`입니다. 위 표의 헤더를 쓰세요. |
| Vercel에서 제한이 무작위로 초기화되는 것 같음 | 함수 인스턴스마다 메모리가 따로 있습니다. 버킷을 Redis로 옮기세요. |
| 개발 환경에서 위젯이 두 번 렌더링됨 | React의 Strict Mode는 이펙트를 두 번 마운트합니다. 정리 함수가 첫 번째 위젯을 제거하므로 프로덕션에서는 하나만 보입니다. |

## 질문

### TanStack Start 서버 함수에서 클라이언트 IP를 어떻게 얻나요?

@tanstack/react-start/server의 getRequestHeader로 호스트가 설정하는 헤더를 읽으세요. 이상적으로는 함수 미들웨어에서 한 번 읽어 핸들러에 컨텍스트로 넘깁니다. Vercel은 x-forwarded-for, Netlify는 x-nf-client-connection-ip, Cloudflare는 cf-connecting-ip를 쓰세요.

### TanStack Start 서버 함수에 CSRF 보호가 있나요?

네. src/start.ts를 정의하지 않으면 Start가 서버 함수용 CSRF 미들웨어를 설치하며, 정의한 경우에는 createCsrfMiddleware를 직접 추가해야 합니다. 이 미들웨어는 스크립트가 아니라 교차 사이트 브라우저 POST를 막으므로, 스팸 검사는 여전히 필요합니다.

### TanStack Start에서 메모리로 레이트 리밋을 걸 수 있나요?

오래 실행되는 Node 서버 하나라면 가능합니다. 서버리스 호스트에서는 인스턴스마다 메모리가 따로 있으므로 Redis나 Cloudflare의 레이트 리밋 바인딩 같은 공유 저장소를 쓰세요.

### Ikiform은 무료인가요?

아니요, 무료 플랜은 없습니다. 테스트는 무료이며, 결제하기 전에 localhost에서 매월 50건의 테스트 제출을 보낼 수 있습니다. 실제로 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 제출 10,000건에 $49 또는 50,000건에 $149로 한 번만 결제할 수 있으며 크레딧은 만료되지 않습니다.

## 가이드 더 보기

- [문의 폼 스팸 막기](https://ikiform.com/ko/guides/spam-protection.md): 허니팟, 시간 검사, 콘텐츠 점수, 레이트 리밋, 필요한 곳에만 사람 확인까지, 실제 방문자에게 부담 없는 여러 겹의 검사로 문의 폼 스팸을 막으세요.
- [레이트 리밋](https://ikiform.com/ko/guides/spam-protection/rate-limiting.md): 공유 저장소로 방문자별 폼 제출에 레이트 리밋을 걸고, Retry-After와 함께 429로 응답하며, 하나의 IP를 쓰는 사무실이 막히지 않게 하세요.
- [Cloudflare Turnstile](https://ikiform.com/ko/guides/spam-protection/cloudflare-turnstile.md): 일반 HTML 폼에 Cloudflare Turnstile을 추가하세요. 위젯, 서버 측 siteverify, 테스트 키, 그리고 키나 서버 없이 실행하는 방법까지 다룹니다.
- [캡차 없이](https://ikiform.com/ko/guides/spam-protection/without-captcha.md): 허니팟, 서명된 렌더링 시각, 콘텐츠 점수, 레이트 리밋을 따로가 아니라 합쳐서 써서 캡차 없이 대부분의 폼 스팸을 막으세요.
- [허니팟 필드](https://ikiform.com/ko/guides/spam-protection/honeypot.md): 스크린 리더, 키보드, 자동 완성에 걸리지 않으면서 봇을 잡는 허니팟을 어떤 폼에든 추가하고, 스택별 서버 검사까지 구현하세요.
- [reCAPTCHA 대안](https://ikiform.com/ko/guides/spam-protection/recaptcha-alternatives.md): Turnstile과 hCaptcha부터 작업 증명, 캡차 없는 방식까지 폼용 reCAPTCHA 대안을 비교하고, 작고 안전한 변경으로 전환하세요.
