문의 폼 스팸 막기5분 분량
Turnstile을 이용한 React Router 폼 스팸 방지
Turnstile을 쓰는 라우트 액션, cf-connecting-ip를 키로 하는 Workers 레이트 리밋, .server 모듈, CSP 헤더로 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 호출은 서버에 남습니다.headersexport. 액션이 반환하는 헤더는 라우트의headers함수가 넘겨주지 않으면 문서 응답에 실리지 않습니다. CSP도 여기에 둡니다.useFetcher. 내비게이션 없이 액션으로 POST하므로, 시도 사이에도 페이지와 위젯이 마운트된 채로 남습니다. 그래서 응답마다key로 위젯을 다시 마운트합니다.
이 페이지는 React Router를 흔히 호스팅하는 Cloudflare Workers에 배포하며, 여기에는 레이트 리밋 바인딩이 내장되어 있습니다. Vercel과 Netlify는 배포 표에서 다룹니다.
- 하이드레이션이 끝나면 폼은 useFetcher로 POST하므로 페이지가 이동하지 않습니다.
- 액션이 IP별로 레이트 리밋을 걸고, Turnstile 토큰을 검증하고, 필드를 전달합니다.
- 결과는 fetcher.data로 도착합니다.
- 위젯의 key가 바뀌므로 다음 시도를 위한 새 위젯과 새 토큰이 준비됩니다.
- 하이드레이션 전에는 같은 폼이 Turnstile 토큰 없이 일반 문서 요청으로 POST됩니다.
- 액션이 siteverify 단계에서 거부합니다.
- headers export가 Retry-After와 CSP를 문서 응답에 실어 보냅니다.
- 컴포넌트는 fetcher.data 대신 actionData를 읽어 오류를 표시합니다.
프로젝트 설정
@cloudflare/vite-plugin을 쓰는 Cloudflare의 React Router 템플릿에서 시작하세요. React Router 8에서 context 인수는 항상 RouterContextProvider이며, Worker 엔트리에서 일반 { cloudflare: { env } } 객체를 넘기면 예외가 발생합니다. 그래서 엔트리는 요청만 넘기고, 라우트는 import { env } from "cloudflare:workers"로 바인딩을 읽습니다. 이 페이지가 다루는 파일은 다음과 같습니다.
app/
routes/
contact.tsx
components/
turnstile.tsx
lib/
spam.server.ts
workers/
app.ts
wrangler.jsonc
.dev.varsimport { 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>;{
"name": "acme-site",
"main": "./workers/app.ts",
"vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
"ratelimits": [
{
"name": "CONTACT_LIMIT",
"namespace_id": "1001",
"simple": { "limit": 5, "period": 60 }
}
]
}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과 시크릿의 타입이 지정되게 하세요.
서버 전용 헬퍼
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테스트를 알아봅니다.
IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요.위젯
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가 싱글 페이지 앱에 권장하는 방식대로 위젯을 명시적으로 렌더링하고, 언마운트될 때 제거합니다.
라우트
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는 본문을 직접 읽고, 문서 응답에 이 헤더들을 실어 보내는 것은headersexport입니다.result는actionData로 대체되므로, 하이드레이션 전에 폼이 POST된 경우에도 오류가 표시됩니다.- 허니팟 가이드처럼
.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에서 읽고 바인딩은 레이트 리밋 페이지에 있는 것 같은 Redis 카운터로 바꾸세요.
Ikiform에 검사 맡기기
폼 하나에 키, 바인딩, CSP까지 운영하는 것이 부담스럽다면 일반 폼을 Ikiform 엔드포인트로 연결하고 액션을 지우세요.
<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일 동안 보관됩니다. 대신 자체 액션을 유지하는 방법은 서버 설정 가이드에서 다룹니다.
작동 확인하기
npm run dev를 실행하고 테스트 키로 메시지를 보내세요. Submissions에 나타납니다. 플랜이 없는 워크스페이스는 매월localhost테스트 50건을 받습니다.- 새로 고치지 않고 두 번째 메시지를 보내세요. 첫 번째 응답 뒤 새 위젯이 나타나고 두 번째 POST가 성공합니다.
- JavaScript를 끄고 제출하세요. 페이지가 사람 확인 오류와 함께 새로 로드됩니다.
- 배포한 뒤 1분 안에 여섯 번 POST하세요. 여섯 번째는
Retry-After: 60과 함께429를 받습니다. - 허니팟을 채워
_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에서 복사하세요. |
질문
라우트 컴포넌트에서 위젯을 렌더링하고, 액션에서 폼 데이터의 cf-turnstile-response를 읽어 시크릿 키와 함께 siteverify로 보내세요. 응답이 위젯에 설정한 action과 함께 success일 때만 계속하세요.
wrangler.jsonc에 레이트 리밋 바인딩을 선언하고, 액션 맨 위에서 cf-connecting-ip 헤더로 만든 키로 limit을 호출하세요. 실패하면 data()로 429를 반환하세요.
문서 요청에서 React Router는 라우트의 headers export가 반환하는 헤더만 보냅니다. 거기서 actionHeaders를 읽어 Retry-After처럼 필요한 것을 복사하세요.
아니요, 무료 플랜은 없습니다. 테스트는 무료이며, 결제하기 전에 localhost에서 매월 50건의 테스트 제출을 보낼 수 있습니다. 실제로 운영하려면 제출 500건에 월 $6부터인 Pro를 쓰거나, 제출 10,000건에 $49 또는 50,000건에 $149로 한 번만 결제할 수 있으며 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.