# Turnstile을 이용한 React Router 폼 스팸 방지

> Turnstile을 쓰는 라우트 액션, cf-connecting-ip를 키로 하는 Workers 레이트 리밋, .server 모듈, CSP 헤더로 React Router 폼을 스팸으로부터 보호하세요.

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

라우트의 `action`에서 검사하세요. 클라이언트 IP로 제한하고, siteverify로 Turnstile 토큰을 검증한 뒤, 허니팟과 함께 필드를 전달합니다. 헬퍼는 브라우저에 절대 전달되지 않도록 `.server.ts` 모듈에 두고, CSP는 라우트의 `headers` export에서 설정하세요. Cloudflare Workers에서는 레이트 리밋 바인딩과 시크릿을 `cloudflare:workers`의 `env`에서 가져옵니다.

## React Router에서 다른 점

프레임워크 모드에서는 `action`이 있는 모든 라우트가 여러분의 폼이든 다른 누구든 POST를 받습니다. React Router는 스팸 검사를 배치하는 방식을 결정하는 세 가지 도구를 줍니다.

- **`.server` 모듈.** `spam.server.ts`라는 이름의 파일은 클라이언트 코드에 번들될 수 없으므로, 컴포넌트가 같은 라우트에서 import하더라도 시크릿과 siteverify 호출은 서버에 남습니다.
- **`headers` export.** 액션이 반환하는 헤더는 라우트의 `headers` 함수가 넘겨주지 않으면 문서 응답에 실리지 않습니다. CSP도 여기에 둡니다.
- **`useFetcher`.** 내비게이션 없이 액션으로 POST하므로, 시도 사이에도 페이지와 위젯이 마운트된 채로 남습니다. 그래서 응답마다 `key`로 위젯을 다시 마운트합니다.

이 페이지는 React Router를 흔히 호스팅하는 Cloudflare Workers에 배포하며, 여기에는 레이트 리밋 바인딩이 내장되어 있습니다. Vercel과 Netlify는 배포 표에서 다룹니다.

다이어그램: JavaScript가 있을 때와 없을 때 POST가 React Router 액션에 도달하는 방식

1. 하이드레이션이 끝나면 폼은 useFetcher로 POST하므로 페이지가 이동하지 않습니다.
2. 액션이 IP별로 레이트 리밋을 걸고, Turnstile 토큰을 검증하고, 필드를 전달합니다.
3. 결과는 fetcher.data로 도착합니다.
4. 위젯의 key가 바뀌므로 다음 시도를 위한 새 위젯과 새 토큰이 준비됩니다.
5. 하이드레이션 전에는 같은 폼이 Turnstile 토큰 없이 일반 문서 요청으로 POST됩니다.
6. 액션이 siteverify 단계에서 거부합니다.
7. headers export가 Retry-After와 CSP를 문서 응답에 실어 보냅니다.
8. 컴포넌트는 fetcher.data 대신 actionData를 읽어 오류를 표시합니다.

사람 확인에는 JavaScript가 필요하므로, JavaScript가 없는 경로는 조용히 통과하지 않고 명확한 오류로 끝납니다.

## 프로젝트 설정

`@cloudflare/vite-plugin`을 쓰는 Cloudflare의 React Router 템플릿에서 시작하세요. React Router 8에서 `context` 인수는 항상 `RouterContextProvider`이며, Worker 엔트리에서 일반 `{ cloudflare: { env } }` 객체를 넘기면 예외가 발생합니다. 그래서 엔트리는 요청만 넘기고, 라우트는 `import { env } from "cloudflare:workers"`로 바인딩을 읽습니다. 이 페이지가 다루는 파일은 다음과 같습니다.

`파일`:

```text
app/
  routes/
    contact.tsx
  components/
    turnstile.tsx
  lib/
    spam.server.ts
workers/
  app.ts
wrangler.jsonc
.dev.vars
```

`workers/app.ts`:

```ts
import { createRequestHandler } from "react-router";

const handler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE);

export default {
  fetch(request) {
    return handler(request);
  },
} satisfies ExportedHandler<Env>;
```

`wrangler.jsonc`:

```json
{
  "name": "acme-site",
  "main": "./workers/app.ts",
  "vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
  "ratelimits": [
    {
      "name": "CONTACT_LIMIT",
      "namespace_id": "1001",
      "simple": { "limit": 5, "period": 60 }
    }
  ]
}
```

`.dev.vars`:

```bash
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

- site key는 공개 값이므로 `vars`에 둡니다. 로더가 이를 컴포넌트에 넘기므로 클라이언트 빌드에는 환경 변수가 전혀 필요 없습니다.
- 시크릿은 로컬에서는 `.dev.vars`에, 프로덕션에서는 `wrangler secret put`으로 두고, 절대 `wrangler.jsonc`에 넣지 마세요.
- 설정을 수정한 뒤 `wrangler types`를 실행해 `env`에서 `CONTACT_LIMIT`과 시크릿의 타입이 지정되게 하세요.

## 서버 전용 헬퍼

`app/lib/spam.server.ts`:

```ts
export async function isHuman(secret: string, token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  const body = new URLSearchParams({ secret, response: token, remoteip: ip });
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (import.meta.env.DEV || verdict.hostname === host);
  } catch {
    return false;
  }
}

export async function forward(secretKey: string, form: FormData, origin: string | null) {
  const headers = new Headers({ Accept: "application/json", Authorization: `Bearer ${secretKey}` });
  if (origin) headers.set("Origin", origin);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", { method: "POST", headers, body: form }).catch(() => null);
  return response?.ok ?? false;
}
```

- `.server` 접미사는 클라이언트 코드가 이 모듈을 포함하려 할 때 빌드를 실패시킵니다.
- siteverify는 form-encoded 본문을 받습니다. 호스트 이름 검사는 방문자가 POST한 호스트와 비교하며, 테스트 토큰에는 더미 호스트 이름이 있으므로 개발 환경에서는 건너뜁니다.
- `forward`는 원래의 `FormData`를 보내므로 허니팟을 포함한 모든 필드가 POST된 그대로 Ikiform에 도달합니다.
- 서버 측 fetch는 `Origin`을 보내지 않으므로 방문자의 이 헤더도 넘기며, Ikiform은 이를 읽어 무료 `localhost` 테스트를 알아봅니다.

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

## 위젯

`app/components/turnstile.tsx`:

```tsx
import { useEffect, useRef } from "react";

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

interface TurnstileApi {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

export function Turnstile({ siteKey }: { siteKey: string }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    const mount = () => {
      if (box.current && window.turnstile) widgetId = window.turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    };
    if (window.turnstile) mount();
    else document.querySelector(`script[src="${SCRIPT}"]`)?.addEventListener("load", mount, { once: true });
    return () => {
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey]);

  return (
    <>
      <script src={SCRIPT} async />
      <div ref={box} />
    </>
  );
}
```

React 19는 `async` `<script>`를 head로 끌어올리고, 컴포넌트가 몇 번 렌더링되든 한 번만 불러옵니다. 이펙트는 Cloudflare가 싱글 페이지 앱에 권장하는 방식대로 위젯을 명시적으로 렌더링하고, 언마운트될 때 제거합니다.

## 라우트

`app/routes/contact.tsx`:

```tsx
import { useEffect, useState } from "react";
import { data, useFetcher } from "react-router";
import { env } from "cloudflare:workers";
import { Turnstile } from "~/components/turnstile";
import { forward, isHuman } from "~/lib/spam.server";
import type { Route } from "./+types/contact";

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'",
].join("; ");

export function loader() {
  return { siteKey: env.TURNSTILE_SITE_KEY };
}

export async function action({ request }: Route.ActionArgs) {
  const ip = request.headers.get("cf-connecting-ip") ?? "unknown";

  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  if (!success) {
    return data({ ok: false, error: "Too many messages. Try again in a minute." }, { status: 429, headers: { "Retry-After": "60" } });
  }

  const form = await request.formData();
  const token = String(form.get("cf-turnstile-response") ?? "");
  if (!(await isHuman(env.TURNSTILE_SECRET_KEY, token, ip, new URL(request.url).hostname))) {
    return data({ ok: false, error: "We couldn't confirm you're human. Try again." }, { status: 403 });
  }

  form.delete("cf-turnstile-response");
  const sent = await forward(env.IKIFORM_SECRET_KEY, form, request.headers.get("origin"));
  return sent ? { ok: true, error: "" } : data({ ok: false, error: "Your message didn't send. Try again." }, { status: 502 });
}

export function headers({ actionHeaders, parentHeaders }: Route.HeadersArgs) {
  parentHeaders.set("Content-Security-Policy", POLICY);
  const retry = actionHeaders.get("Retry-After");
  if (retry) parentHeaders.set("Retry-After", retry);
  return parentHeaders;
}

export default function Contact({ loaderData, actionData }: Route.ComponentProps) {
  const fetcher = useFetcher<typeof action>();
  const result = fetcher.data ?? actionData;
  const [round, setRound] = useState(0);

  useEffect(() => {
    if (fetcher.state === "idle" && fetcher.data) setRound((count) => count + 1);
  }, [fetcher.state, fetcher.data]);

  if (result?.ok) return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <fetcher.Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile key={round} siteKey={loaderData.siteKey} />
      {result?.error ? <p role="alert">{result.error}</p> : null}
      <button disabled={fetcher.state !== "idle"}>{fetcher.state === "idle" ? "Send message" : "Sending"}</button>
    </fetcher.Form>
  );
}
```

- 제한은 `request.formData()`보다 먼저 실행되므로, 폭주가 본문 파싱이나 siteverify 호출 비용을 쓰지 않습니다.
- `cf-connecting-ip`는 Worker로 오는 모든 요청에 Cloudflare가 설정하며 방문자가 설정할 수 없습니다.
- `data()`가 상태와 `Retry-After`를 설정합니다. fetcher는 본문을 직접 읽고, 문서 응답에 이 헤더들을 실어 보내는 것은 `headers` export입니다.
- `result`는 `actionData`로 대체되므로, 하이드레이션 전에 폼이 POST된 경우에도 오류가 표시됩니다.
- [허니팟 가이드](https://ikiform.com/ko/guides/spam-protection/honeypot.md)처럼 `.hp` 클래스가 허니팟을 화면 밖으로 숨깁니다.

React Router는 하이드레이션을 위해 인라인 스크립트를 렌더링하므로, 이 정책은 `script-src`에 `'unsafe-inline'`을 유지합니다. 이를 없애려면 `entry.server.tsx`에서 nonce를 생성해 `<Scripts>`와 Turnstile 스크립트에 넘기세요. Cloudflare에 따르면 Turnstile은 불러오는 리소스에 nonce를 넘깁니다.

## 로컬에서 테스트하기

Cloudflare 문서에 따르면 Vite 플러그인은 개발 중에 서버 코드를 Workers 런타임에서 실행하므로, `cloudflare:workers`의 `env`, `.dev.vars`, 레이트 리밋 바인딩이 프로덕션과 똑같이 동작합니다. 로컬에서는 Cloudflare 엣지가 `cf-connecting-ip`를 설정하지 않으므로 모든 요청이 `unknown`으로 집계되어 하나의 제한을 공유합니다. 예상된 동작입니다. 실제 헤더를 보려면 `wrangler versions upload`로 미리보기를 배포하고 두 네트워크에서 테스트하세요.

## 다른 곳에 배포하기

| 호스트 | 클라이언트 IP | 리미터 | 시크릿 |
| --- | --- | --- | --- |
| Cloudflare Workers | `cf-connecting-ip` | 레이트 리밋 바인딩 | `wrangler secret put` |
| Vercel | `x-forwarded-for`, Vercel이 덮어씀 | Redis | `process.env` |
| Netlify | `x-nf-client-connection-ip` | Redis | `process.env` |

Cloudflare는 바인딩이 근사치이며 위치별로 집계된다고 설명하며, 문의 폼에는 문제없습니다. Workers가 아니면 `cloudflare:workers` 모듈이 없으므로, 시크릿은 `process.env`에서 읽고 바인딩은 [레이트 리밋 페이지](https://ikiform.com/ko/guides/spam-protection/rate-limiting.md)에 있는 것 같은 Redis 카운터로 바꾸세요.

## Ikiform에 검사 맡기기

폼 하나에 키, 바인딩, CSP까지 운영하는 것이 부담스럽다면 일반 폼을 Ikiform 엔드포인트로 연결하고 액션을 지우세요.

`app/routes/contact.tsx`:

```tsx
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label htmlFor="email">Email</label>
  <input id="email" name="email" type="email" autoComplete="email" required />
  <label htmlFor="message">Message</label>
  <textarea id="message" name="message" rows={5} required />
  <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
  <button>Send message</button>
</form>
```

브라우저가 Ikiform으로 바로 POST하도록 React Router의 `<Form>`이 아니라 소문자 `<form>`을 쓰세요. **Spam check**를 **Ikiform**으로 설정하면 토큰 없는 POST에 ikiform.com의 짧은 확인 페이지가 표시되어 Turnstile을 실행하고 필드를 이어서 보냅니다. **Rate limit**은 방문자당 10분에 2건인 Strict를 고르고, **Allowed domains**에 도메인을 등록하세요. 걸러 낸 스팸은 실제 메시지와 같은 응답을 받고 30일 동안 보관됩니다. 대신 자체 액션을 유지하는 방법은 [서버 설정 가이드](https://ikiform.com/ko/docs/guides/server.md)에서 다룹니다.

## 작동 확인하기

1. `npm run dev`를 실행하고 테스트 키로 메시지를 보내세요. **Submissions**에 나타납니다. 플랜이 없는 워크스페이스는 매월 `localhost` 테스트 50건을 받습니다.
2. 새로 고치지 않고 두 번째 메시지를 보내세요. 첫 번째 응답 뒤 새 위젯이 나타나고 두 번째 POST가 성공합니다.
3. JavaScript를 끄고 제출하세요. 페이지가 사람 확인 오류와 함께 새로 로드됩니다.
4. 배포한 뒤 1분 안에 여섯 번 POST하세요. 여섯 번째는 `Retry-After: 60`과 함께 `429`를 받습니다.
5. 허니팟을 채워 `_gotcha=bot`으로 POST하세요. 여전히 성공 응답을 받지만, 제출은 스팸으로 저장되고 이메일이나 웹훅을 보내지 않습니다.

## 주의할 점

| 문제 | 해결 방법 |
| --- | --- |
| `Server-only module referenced by client` | 컴포넌트가 렌더링하는 무언가를 `spam.server.ts`에서 import하고 있습니다. 이 모듈은 `loader`와 `action`에서만 쓸 수 있습니다. |
| 두 번째 시도가 항상 siteverify에서 실패함 | 위젯이 이미 쓴 토큰을 유지하고 있습니다. fetcher가 응답할 때마다 위젯의 `key`가 바뀌는지 확인하세요. |
| `Invalid context value provided to handleRequest` | Worker 엔트리가 React Router 7 템플릿처럼 아직 `{ cloudflare: { env, ctx } }`를 넘기고 있습니다. 요청만 넘겨 핸들러를 호출하고, 바인딩은 `cloudflare:workers`에서 읽으세요. |
| JavaScript가 없는 응답에 `Retry-After`가 없음 | 액션 헤더는 `headers` export가 반환하지 않으면 버려집니다. 예제처럼 `actionHeaders`에서 복사하세요. |

## 질문

### React Router 액션에 Turnstile을 어떻게 추가하나요?

라우트 컴포넌트에서 위젯을 렌더링하고, 액션에서 폼 데이터의 cf-turnstile-response를 읽어 시크릿 키와 함께 siteverify로 보내세요. 응답이 위젯에 설정한 action과 함께 success일 때만 계속하세요.

### Cloudflare Workers에서 React Router 액션에 레이트 리밋을 어떻게 거나요?

wrangler.jsonc에 레이트 리밋 바인딩을 선언하고, 액션 맨 위에서 cf-connecting-ip 헤더로 만든 키로 limit을 호출하세요. 실패하면 data()로 429를 반환하세요.

### 액션의 헤더가 왜 브라우저에 도달하지 않나요?

문서 요청에서 React Router는 라우트의 headers export가 반환하는 헤더만 보냅니다. 거기서 actionHeaders를 읽어 Retry-After처럼 필요한 것을 복사하세요.

### Ikiform은 무료인가요?

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

## 가이드 더 보기

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